Merge remote-tracking branch 'origin/main' into worktree-mutable-discovering-valiant
# Conflicts: # docs/content/docs/integrations/langgraph/doctest.json # lefthook.yml # pnpm-lock.yaml
@@ -6,6 +6,12 @@
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Shell scripts must retain LF line endings. Windows contributors regenerating
|
||||
# showcase starters on a Windows checkout (or with autocrlf=true) would
|
||||
# otherwise silently ship CRLF ``entrypoint.sh`` files that bash in the
|
||||
# Docker runtime rejects (``bad interpreter: No such file or directory``).
|
||||
*.sh text eol=lf
|
||||
|
||||
# Generated showcase starters — do not edit manually
|
||||
# Regenerate with: cd showcase/scripts && npx tsx generate-starters.ts
|
||||
showcase/starters/ag2/** linguist-generated=true
|
||||
|
||||
@@ -25,6 +25,12 @@ env:
|
||||
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
NX_CI_EXECUTION_ENV: "E2E Examples"
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -32,7 +38,7 @@ concurrency:
|
||||
jobs:
|
||||
examples:
|
||||
name: ${{ matrix.example }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -1,26 +1,125 @@
|
||||
name: "Showcase: Aimock E2E Tests"
|
||||
|
||||
# SECURITY — residual trust model (read before editing):
|
||||
#
|
||||
# This workflow EXISTS to execute PR-HEAD code (Playwright tests, Next.js dev
|
||||
# server, Python agent, pip install of PR-controlled requirements.txt). Several
|
||||
# hardening layers reduce blast radius:
|
||||
# - `author_association` gate limits the `issue_comment` trigger to OWNER /
|
||||
# MEMBER / COLLABORATOR (third-party commenters cannot spawn runs).
|
||||
# - workflow-level `permissions: contents: read` means the heavy test job's
|
||||
# GITHUB_TOKEN cannot mutate the repo; the `post-result` job gets write
|
||||
# perms scoped to just the final PR comment.
|
||||
# - `persist-credentials: false` on `actions/checkout` prevents the token
|
||||
# from being left behind in `.git/config` where PR-HEAD build hooks might
|
||||
# read it.
|
||||
# - `pnpm install --ignore-scripts` / `npm install --ignore-scripts` block
|
||||
# install-time hooks in PR-controlled JS manifests from executing on the
|
||||
# runner. The Python install uses `pip install --prefer-binary` (prefers
|
||||
# wheels, falls back to sdist on transitive deps that lack a wheel for
|
||||
# linux-x86_64/py3.12). We used to use `--only-binary :all:` for a hard
|
||||
# block against source-build hooks, but CrewAI's transitive graph
|
||||
# (tiktoken / chromadb / litellm cadence releases) regularly ships a
|
||||
# sdist-only revision that makes every CI run fail-loud with "Could not
|
||||
# find a version that satisfies the requirement". `--prefer-binary` trades
|
||||
# that hard guarantee for reliability — the `author_association` gate
|
||||
# above still limits WHO can trigger this workflow, so the residual risk
|
||||
# is bounded to a trusted commenter. See also the "Start Python agent"
|
||||
# step for the in-context trade-off rationale.
|
||||
# - A strict slug whitelist (`^[a-z0-9-]+$` + existing-dir check) and the
|
||||
# `env:`-based pattern for UNTRUSTED values (comment body, dispatch slug)
|
||||
# prevent shell injection / path traversal.
|
||||
#
|
||||
# What this is NOT: a security boundary against a malicious trusted commenter.
|
||||
# The last line of defense is the SOCIAL CONTRACT that a trusted commenter
|
||||
# reviews the PR diff BEFORE typing `/test-aimock` — if a compromised / rogue
|
||||
# OWNER/MEMBER/COLLABORATOR comments on an attacker's PR, they get a full
|
||||
# runner exec with the job's token. That is an accepted residual risk for the
|
||||
# developer-velocity benefit of PR-triggered E2E runs. Do not loosen the
|
||||
# `author_association` gate without revisiting the threat model above.
|
||||
#
|
||||
# Known TOCTOU — comment-trigger vs resolved HEAD SHA:
|
||||
# "Resolve PR HEAD ref" below calls `pulls.get` at job start. There is a
|
||||
# window between the trusted commenter typing `/test-aimock` (reviewed diff
|
||||
# D1) and the workflow actually calling `pulls.get` (resolves whatever HEAD
|
||||
# is current — possibly D2 after a force-push). A PR author who force-pushes
|
||||
# malicious content AFTER the trusted comment but BEFORE the resolve call
|
||||
# gets their code executed. GitHub Actions does NOT natively support pinning
|
||||
# the SHA at comment time (no `comment.commit_sha` equivalent), so this gap
|
||||
# is architectural. The `author_association` gate + code-review social
|
||||
# contract are the mitigations; the residual TOCTOU risk is accepted. If
|
||||
# GitHub ever ships a comment-time SHA field, pin to it and drop this note.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
slug:
|
||||
description: "Package slug to test (e.g. langgraph-python), or 'all'"
|
||||
required: false
|
||||
default: "langgraph-python"
|
||||
description: "Package slug to test (must ship aimock_toggle.py)"
|
||||
required: true
|
||||
# Only crewai-crews currently ships aimock_toggle.py and exercises
|
||||
# the AIMOCK_URL path end-to-end. Restricting the enum here prevents
|
||||
# accidental dispatch of a TS-only (mastra) or Java (spring-ai) slug
|
||||
# that would skip the Python agent startup step and then fail with a
|
||||
# misleading Playwright timeout instead of a clear "no toggle shipped"
|
||||
# error. When a new Python slug adds aimock_toggle.py, append it here.
|
||||
#
|
||||
# No `default:` is set — the operator must pick a slug explicitly. A
|
||||
# hidden default would silently bind manual dispatches to whichever
|
||||
# slug happens to be first in the enum, which contradicts the
|
||||
# "no silent fallback" guarantee the comment-path extractor enforces.
|
||||
#
|
||||
# Single-choice enum UX note: the GitHub Actions UI pre-selects the
|
||||
# only option when a `choice` has one entry. That IS the intended
|
||||
# experience here — with exactly one valid slug today, showing a
|
||||
# disabled dropdown matches what "the operator must pick a slug"
|
||||
# reduces to when the valid set has size one. Do not add a sentinel
|
||||
# option (e.g. "--choose--") to force a picker — sentinel values
|
||||
# would need separate validation and re-introduce the silent-fallback
|
||||
# class of bug the comment-path extractor was hardened against.
|
||||
type: choice
|
||||
options:
|
||||
- crewai-crews
|
||||
|
||||
# Default to read-only at the job level. The only step that needs write access
|
||||
# is "Post result to PR" at the end — we grant it write perms inline there.
|
||||
# Keeping the workflow-level perms read-only means every intermediate step
|
||||
# (including `pip install` on attacker-controlled requirements.txt) runs with
|
||||
# a token that cannot mutate the repo.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
aimock-e2e:
|
||||
# Only run on PR comments matching /test-aimock, or manual dispatch
|
||||
# Only run on PR comments matching `/test-aimock ` (trailing space REQUIRED)
|
||||
# from trusted authors, or manual dispatch. The trailing space tightens
|
||||
# the match so unrelated text like `/test-aimocker` or `don't /test-aimock-like-this`
|
||||
# does NOT trigger the workflow. The author_association gate additionally
|
||||
# prevents arbitrary third-party commenters from triggering runs with
|
||||
# attacker-controlled comment bodies (which the 'Determine slug' step then
|
||||
# parses — see env-based shell interpolation below). A bare `/test-aimock`
|
||||
# alone (no trailing space) is rejected by design; commenters must pick a
|
||||
# slug explicitly — no silent fallback to crewai-crews (see "Determine slug"
|
||||
# step below).
|
||||
# `startsWith` (not `contains`) is the Actions-level gate: it requires
|
||||
# `/test-aimock ` to be the FIRST token of the comment, so embedded mentions
|
||||
# (in code blocks, quoted replies, or mid-sentence prose) cannot spin up a
|
||||
# runner. The shell extractor in the "Determine slug" step uses the same
|
||||
# leading anchor (`^/test-aimock[[:space:]]+…`) as defense-in-depth; both
|
||||
# layers agree on "first token only" so a future edit that loosens either
|
||||
# layer alone cannot bypass validation. Commenters who
|
||||
# want to add narration around the command should put the command on its
|
||||
# own line at the top of the comment.
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/test-aimock'))
|
||||
(github.event.issue.pull_request
|
||||
&& startsWith(github.event.comment.body, '/test-aimock ')
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||
# Pinned to ubuntu-latest deliberately: the 'Determine slug' step uses
|
||||
# POSIX-only `grep -oE` + `sed` (no `grep -oP` / PCRE) so a future BSD
|
||||
# grep would still work, but ubuntu-latest keeps the install/setup matrix
|
||||
# consistent with every other showcase workflow.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -38,32 +137,104 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
// Refuse to run against a closed / merged PR. A trusted commenter
|
||||
// typing `/test-aimock` on a stale closed PR would otherwise
|
||||
// re-exec the old HEAD — either wasting CI or (if the PR was
|
||||
// closed BECAUSE it was bad) re-running known-bad code. Fail loud.
|
||||
if (pr.state !== 'open') {
|
||||
core.setFailed(`PR #${pr.number} is ${pr.state} (not open). Refusing to run E2E on a non-open PR.`);
|
||||
return;
|
||||
}
|
||||
core.setOutput('ref', pr.head.sha);
|
||||
core.setOutput('pr_number', pr.number);
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.pr-ref.outputs.ref || github.sha }}
|
||||
# Do NOT leave the workflow's GITHUB_TOKEN in `.git/config` after
|
||||
# checkout. PR-HEAD code (pip build hooks, Next.js dev scripts,
|
||||
# Playwright fixtures) runs on this runner; a credential left in the
|
||||
# working tree could be read by that code and exfiltrated. The job's
|
||||
# `permissions: contents: read` limits blast radius, but defense-in-
|
||||
# depth cheap — disable credential persistence.
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Determine slug
|
||||
id: slug
|
||||
# SECURITY: comment body and dispatch slug are UNTRUSTED. Pass via env
|
||||
# (NOT via `${{ ... }}` expression interpolation) so shell never parses
|
||||
# attacker-controlled text. Then validate against a strict whitelist
|
||||
# before anything downstream uses $SLUG as a path / package name — so
|
||||
# `../../../etc/shadow` or similar cannot reach `cd`/`pip install`.
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
DISPATCH_SLUG: ${{ github.event.inputs.slug }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "slug=${{ github.event.inputs.slug }}" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
SLUG="$DISPATCH_SLUG"
|
||||
else
|
||||
COMMENT="${{ github.event.comment.body }}"
|
||||
SLUG=$(echo "$COMMENT" | grep -oP '/test-aimock\s+\K\S+' || echo "langgraph-python")
|
||||
echo "slug=$SLUG" >> "$GITHUB_OUTPUT"
|
||||
# POSIX-safe extraction (no `grep -oP` / PCRE `\K`): match
|
||||
# `/test-aimock` ONLY at the start of the comment body, followed
|
||||
# by whitespace + a slug. The leading anchor (^) matches exactly
|
||||
# what the job-level `if:` gate enforces via
|
||||
# `startsWith(github.event.comment.body, '/test-aimock ')` — both
|
||||
# layers agree that the command must be the FIRST token of the
|
||||
# body, so an edit that loosens either layer cannot accidentally
|
||||
# desynchronize from the other. This blocks
|
||||
# `/test-aimocker` or mid-line mentions from matching.
|
||||
# Works on both GNU grep (ubuntu-latest) and BSD grep.
|
||||
SLUG=$(printf '%s' "$COMMENT_BODY" \
|
||||
| grep -oE '^/test-aimock[[:space:]]+[^[:space:]]+' \
|
||||
| head -n1 \
|
||||
| sed 's|^/test-aimock[[:space:]]*||' \
|
||||
|| true)
|
||||
# No default slug fallback. A bare `/test-aimock` (no slug) or a
|
||||
# match that only skimmed our boundary (e.g. `/test-aimocker x`)
|
||||
# FAILS the workflow rather than silently running against
|
||||
# crewai-crews. A hidden default is a footgun: a trusted commenter
|
||||
# typing `don't /test-aimock-like-this` would otherwise spawn a
|
||||
# full CI run against the wrong package.
|
||||
if [ -z "$SLUG" ]; then
|
||||
echo "::error::No slug provided. Usage: '/test-aimock <slug>' (e.g. '/test-aimock crewai-crews')"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Strict slug whitelist: lowercase alphanumerics + hyphens only. This
|
||||
# blocks path traversal (`../`), absolute paths, command substitution,
|
||||
# and anything else that could escape `showcase/packages/$SLUG`.
|
||||
case "$SLUG" in
|
||||
''|*[!a-z0-9-]*)
|
||||
echo "::error::Invalid slug '$SLUG' — must match ^[a-z0-9-]+$"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Belt-and-suspenders: the slug must correspond to an existing package
|
||||
# directory. Rejects typos and anything that bypasses the regex.
|
||||
if [ ! -d "showcase/packages/$SLUG" ]; then
|
||||
echo "::error::Slug '$SLUG' does not map to showcase/packages/$SLUG"
|
||||
exit 1
|
||||
fi
|
||||
echo "slug=$SLUG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# NOTE on `${{ steps.slug.outputs.slug }}` vs `env:` pattern:
|
||||
# Downstream steps interpolate `steps.slug.outputs.slug` directly into
|
||||
# the shell script body. This is SAFE here because the "Determine slug"
|
||||
# step above whitelists the value against `^[a-z0-9-]+$` AND rejects any
|
||||
# slug that doesn't map to an existing package directory — so the value
|
||||
# that reaches these interpolations is always a trusted, validated
|
||||
# identifier. We still use the `env:`-based defensive default for
|
||||
# downstream script bodies that handle anything else UNTRUSTED (see the
|
||||
# `actions/github-script` step at the bottom of the workflow).
|
||||
- name: Detect package type
|
||||
id: pkg-type
|
||||
run: |
|
||||
@@ -74,64 +245,223 @@ jobs:
|
||||
else
|
||||
echo "has_python=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# The workflow's downstream steps (dev-server + Playwright) assume
|
||||
# a Python agent listening on :8000. Dispatching a TS (mastra) or
|
||||
# Java (spring-ai) slug would skip the Python agent start step and
|
||||
# then fail with a misleading Playwright timeout. Short-circuit
|
||||
# with a clear error instead — the workflow_dispatch enum narrows
|
||||
# this at the UI layer, but a comment-trigger slug bypasses that.
|
||||
if [ -f "$PKG_DIR/src/aimock_toggle.py" ]; then
|
||||
echo "ships_toggle=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Slug '$SLUG' does not ship src/aimock_toggle.py — this workflow only exercises packages that wire AIMOCK_URL end-to-end. Add aimock_toggle.py (and requirements.txt) to the package first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Start aimock
|
||||
run: |
|
||||
npm install -g @copilotkit/aimock@latest
|
||||
npx aimock --port 4010 --host 127.0.0.1 --fixtures showcase/aimock/feature-parity.json &
|
||||
# Wait for aimock to be ready
|
||||
# Pin aimock to a known-good floor (caret = safe patch/minor).
|
||||
# An unrestricted `@latest` means a bad aimock publish silently
|
||||
# poisons CI for everyone; pinning makes the upgrade explicit and
|
||||
# keeps this PR's CI signal reproducible.
|
||||
npm install -g "@copilotkit/aimock@^1.14.3" --ignore-scripts
|
||||
# Invoke the installed global binary directly rather than `npx`.
|
||||
# `npx @copilotkit/aimock@^1.14.3` re-resolves the spec against the
|
||||
# registry and MAY re-fetch a package even when an identical global
|
||||
# install exists — which would defeat `--ignore-scripts` on the
|
||||
# npm-install step above (npx's transient install does not inherit
|
||||
# that flag) and would also defeat the caret pin if a new patch
|
||||
# published between those two invocations.
|
||||
#
|
||||
# `npm prefix -g` returns the npm global prefix; its `bin/` subdir
|
||||
# holds globally-installed binaries. We deliberately avoid `npm bin
|
||||
# -g` here: the `bin` subcommand was removed in npm 9.0.0 and
|
||||
# `setup-node@v4` with node 22.x ships npm 10+, so `npm bin -g`
|
||||
# would exit with "Unknown command: 'bin'" and the existence check
|
||||
# below would fire every run. `npm prefix -g` has been stable since
|
||||
# npm 7 and returns the prefix on every supported version. The
|
||||
# `$(npm prefix -g)/bin` dir is also on PATH via setup-node, so we
|
||||
# could call `aimock` directly — keeping the absolute-path pattern
|
||||
# + existence check as defense-in-depth against a PATH-shadowing
|
||||
# binary sneaking in from a prior step on the runner.
|
||||
AIMOCK_BIN="$(npm prefix -g)/bin/aimock"
|
||||
if [ ! -x "$AIMOCK_BIN" ]; then
|
||||
echo "::error::aimock binary not found at $AIMOCK_BIN after global install"
|
||||
exit 1
|
||||
fi
|
||||
"$AIMOCK_BIN" --port 4010 --host 127.0.0.1 --fixtures showcase/aimock/feature-parity.json --validate-on-load &
|
||||
AIMOCK_PID=$!
|
||||
echo "AIMOCK_PID=$AIMOCK_PID" >> "$GITHUB_ENV"
|
||||
# Wait for aimock to be ready. Capture the PID + `kill -0` inside
|
||||
# the loop so an aimock that crashes on startup (bad fixture path,
|
||||
# port in use, binary import error) fails fast instead of burning
|
||||
# the full 20s polling a dead process.
|
||||
#
|
||||
# Probe `/__aimock/health` — aimock's actual readiness endpoint.
|
||||
# Root `/` returns HTTP 404 (aimock serves `/__aimock/*` and `/v1/*`
|
||||
# only), and `curl -sf` treats 404 as failure, so probing `/` would
|
||||
# loop until the budget expired and then hard-fail every run.
|
||||
#
|
||||
# `--max-time 2 --connect-timeout 1` caps each probe so a hung
|
||||
# socket cannot blow the loop's 20-iteration budget.
|
||||
for i in $(seq 1 20); do
|
||||
curl -sf http://localhost:4010/ > /dev/null 2>&1 && break
|
||||
if ! kill -0 "$AIMOCK_PID" 2>/dev/null; then
|
||||
echo "::error::aimock process (PID $AIMOCK_PID) exited before becoming ready — check the preceding aimock stdout/stderr."
|
||||
exit 1
|
||||
fi
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://localhost:4010/ || { echo "aimock failed to start"; exit 1; }
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health || { echo "aimock failed to start"; exit 1; }
|
||||
|
||||
- name: Setup Python agent
|
||||
if: steps.pkg-type.outputs.has_python == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
# Cache pip to avoid reinstalling CrewAI's heavy transitive dep
|
||||
# tree on every PR run. Key scopes to the selected slug so each
|
||||
# package gets its own cache bucket keyed on its requirements.txt.
|
||||
cache: "pip"
|
||||
cache-dependency-path: showcase/packages/${{ steps.slug.outputs.slug }}/requirements.txt
|
||||
|
||||
- name: Start Python agent
|
||||
if: steps.pkg-type.outputs.has_python == 'true'
|
||||
# NOTE: this workflow tests the SOURCE PACKAGE directly
|
||||
# (showcase/packages/<slug>), whose dev script binds the agent on port
|
||||
# 8000. Generated STARTERS (showcase/starters/<slug>) instead bind on
|
||||
# port 8123 — that's the production scaffold users copy. If you're
|
||||
# debugging a scaffolded starter, the health checks here won't apply.
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
cd "showcase/packages/$SLUG"
|
||||
pip install -r requirements.txt
|
||||
OPENAI_BASE_URL=http://localhost:4010/v1 OPENAI_API_KEY=test-key python -m uvicorn agent:app --host 127.0.0.1 --port 8000 &
|
||||
# Wait for agent to be ready
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:8000/health > /dev/null 2>&1 && break
|
||||
curl -sf http://localhost:8000/ > /dev/null 2>&1 && break
|
||||
# SECURITY / RELIABILITY trade-off: `pip install` runs setup.py /
|
||||
# PEP 517 build hooks from PR-controlled packages. Unlike npm / pnpm
|
||||
# there is no `--ignore-scripts` flag for pip; the closest equivalent
|
||||
# is `--only-binary :all:` (wheel-only, blocks source-build hooks).
|
||||
#
|
||||
# We previously used `--only-binary :all:` but CrewAI's dependency
|
||||
# graph (tiktoken / chromadb / litellm etc.) regularly ships a
|
||||
# sdist-only revision of a transitive dep. That made every CI run
|
||||
# fail with "Could not find a version that satisfies the requirement"
|
||||
# — not a security win but a CI outage. `--prefer-binary` keeps the
|
||||
# wheel-first preference (most installs remain hook-free) and only
|
||||
# falls back to sdist when a wheel isn't published for
|
||||
# linux-x86_64/py3.12. The `author_association` gate at the job
|
||||
# level still restricts WHO can trigger this workflow, so the
|
||||
# residual source-build-hook risk is bounded to a trusted commenter.
|
||||
#
|
||||
# If a future requirements.txt needs an even stronger guarantee,
|
||||
# `--require-hashes` + a fully hash-pinned requirements.txt blocks
|
||||
# swap-in attacks while allowing the pinned source build to run.
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
# All currently-dispatchable slugs (the `workflow_dispatch` enum +
|
||||
# the "ships_toggle" short-circuit in the step above) ship
|
||||
# src/agent_server.py, so the `agent_server:app` entrypoint is the
|
||||
# only path actually exercised by this workflow today. The
|
||||
# existence check below is retained as defense-in-depth: a future
|
||||
# refactor that removes agent_server.py from a supported slug
|
||||
# should fail loudly here rather than silently fall through to a
|
||||
# guessed module name and then die mid-Playwright.
|
||||
if [ ! -f "src/agent_server.py" ]; then
|
||||
echo "::error::Slug '$SLUG' is missing src/agent_server.py — this workflow requires the FastAPI entrypoint. Add the file to the package or widen this step."
|
||||
exit 1
|
||||
fi
|
||||
export PYTHONPATH="$PWD/src:${PYTHONPATH:-}"
|
||||
APP_MODULE="agent_server:app"
|
||||
# Set AIMOCK_URL ONLY (not OPENAI_BASE_URL). Packages that ship
|
||||
# aimock_toggle.py MUST prove the toggle itself wires OPENAI_BASE_URL
|
||||
# + LITELLM_API_BASE + dummy key. Pre-setting OPENAI_BASE_URL here
|
||||
# would make a green E2E indistinguishable from "toggle worked"
|
||||
# vs "OPENAI_BASE_URL was already set before the toggle ran" — so
|
||||
# we deliberately leave the rest for configure_aimock() to inject.
|
||||
# OPENAI_API_KEY is left unset so the toggle's dummy-key injection
|
||||
# path is exercised too.
|
||||
AIMOCK_URL=http://localhost:4010/v1 \
|
||||
python -m uvicorn "$APP_MODULE" --host 127.0.0.1 --port 8000 &
|
||||
# Wait for agent to be ready. CrewAI's cold import (litellm + the
|
||||
# full crew graph) can exceed 60s on a cold runner, so give it 90s
|
||||
# (45 iterations × 2s) before declaring failure. Mirrors the aimock
|
||||
# start pattern: loop + hard-fail so a cryptic Playwright timeout
|
||||
# doesn't mask a bind/startup failure here.
|
||||
#
|
||||
# Only probe `/health` — the root `/` of a FastAPI app is typically
|
||||
# a POST endpoint (AG-UI SSE stream) that fails `curl -sf`. Probing
|
||||
# `/` was copy-paste residue from a prior iteration and added no
|
||||
# signal (the check always failed, which made the fallback dead
|
||||
# code). If a future Python agent doesn't expose `/health`, add a
|
||||
# dedicated readiness endpoint instead of reintroducing `/`.
|
||||
for i in $(seq 1 45); do
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:8000/health > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:8000/health > /dev/null 2>&1 \
|
||||
|| { echo "Python agent failed to start on :8000"; exit 1; }
|
||||
|
||||
- name: Install package dependencies
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
cd "showcase/packages/$SLUG"
|
||||
pnpm install
|
||||
# `--ignore-scripts`: a trusted commenter can run `/test-aimock` on
|
||||
# a PR whose package.json is untrusted content. Without this flag
|
||||
# an attacker's postinstall script would execute on the runner with
|
||||
# the workflow's token. The E2E path (Playwright + Next.js dev) does
|
||||
# not require install-time scripts to succeed.
|
||||
pnpm install --ignore-scripts
|
||||
|
||||
- name: Start dev server
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
cd "showcase/packages/$SLUG"
|
||||
# Invoke `next dev` directly instead of `pnpm dev` — the package's
|
||||
# `pnpm dev` script spawns a SECOND uvicorn on :8000 via concurrently,
|
||||
# but the previous "Start Python agent" step already bound :8000. A
|
||||
# second uvicorn bind there would fail with EADDRINUSE and silently
|
||||
# race the Playwright test against whichever agent happened to win
|
||||
# the port. Running Next directly also keeps the AIMOCK_URL env flow
|
||||
# clean (only the Python agent reads AIMOCK_URL).
|
||||
#
|
||||
# `OPENAI_BASE_URL` + `OPENAI_API_KEY` on Next here are DEFENSIVE ONLY.
|
||||
# In the CrewAI showcase, Next proxies chat traffic to the Python
|
||||
# agent via the CopilotKit runtime — it does not call OpenAI directly.
|
||||
# Setting these on Next still matters if a future route in this
|
||||
# showcase adds a direct OpenAI call (server action, tool call, etc.),
|
||||
# because the default `OPENAI_API_KEY` would fall back to real OpenAI
|
||||
# and the test would pass/fail on real API traffic. The values here
|
||||
# keep that class of leak impossible even if the showcase changes.
|
||||
OPENAI_BASE_URL=http://localhost:4010/v1 \
|
||||
OPENAI_API_KEY=test-key \
|
||||
AGENT_URL=http://localhost:8000 \
|
||||
pnpm dev &
|
||||
# Wait for dev server
|
||||
npx next dev --turbopack &
|
||||
# Wait for dev server. `--max-time 2 --connect-timeout 1` caps each
|
||||
# probe so a hung socket can't blow the loop budget.
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:3000 > /dev/null 2>&1 && break
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:3000 > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
curl -sf http://localhost:3000 || { echo "Dev server failed to start"; exit 1; }
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:3000 || { echo "Dev server failed to start"; exit 1; }
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd "showcase/packages/${{ steps.slug.outputs.slug }}"
|
||||
npx playwright install chromium --with-deps
|
||||
|
||||
- name: Re-probe aimock liveness
|
||||
# aimock was readiness-checked once right after startup, but several
|
||||
# steps (Python agent start, pnpm install, Next dev startup, Playwright
|
||||
# install) may have run for multiple minutes since. If aimock died
|
||||
# during any of that time, Playwright would silently run against real
|
||||
# OpenAI because OPENAI_BASE_URL=http://localhost:4010/v1 still points
|
||||
# at the (now dead) port — curl would refuse the connection, litellm
|
||||
# would fall through to the default OpenAI endpoint, and the test
|
||||
# would pass/fail on REAL traffic with REAL costs. Fail loud before
|
||||
# Playwright runs.
|
||||
run: |
|
||||
if ! curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health > /dev/null 2>&1; then
|
||||
echo "::error::aimock is no longer responding on :4010. Refusing to run Playwright against a dead aimock (would silently hit real OpenAI)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
@@ -139,8 +469,25 @@ jobs:
|
||||
BASE_URL=http://localhost:3000 npx playwright test --reporter=list
|
||||
env:
|
||||
CI: "true"
|
||||
OPENAI_BASE_URL: http://localhost:4010/v1
|
||||
OPENAI_API_KEY: test-key
|
||||
# Dead env — Next.js is already running from the "Start dev server"
|
||||
# step above (which set these inline on that process). Env set here
|
||||
# would only affect the `npx playwright test` process, which does not
|
||||
# read OPENAI_BASE_URL / OPENAI_API_KEY. Leaving unset to avoid the
|
||||
# false impression that these values flow to the running Next server.
|
||||
|
||||
- name: Re-check aimock liveness after Playwright
|
||||
if: always()
|
||||
# Defense-in-depth: aimock might have OOM'd DURING the Playwright run.
|
||||
# If that happened, the test either silently used stale fixtures (no-op
|
||||
# after aimock died if responses were cached) or fell through to real
|
||||
# OpenAI. Fail the job loudly so a dead aimock cannot masquerade as a
|
||||
# green run. Keeps the 4010-is-still-alive invariant symmetric with the
|
||||
# pre-Playwright re-probe above.
|
||||
run: |
|
||||
if [ -n "${AIMOCK_PID:-}" ] && ! kill -0 "$AIMOCK_PID" 2>/dev/null; then
|
||||
echo "::error::aimock process (PID $AIMOCK_PID) died during the Playwright run. Playwright results are untrusted — it may have hit real OpenAI or returned stale fixtures."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: always()
|
||||
@@ -151,17 +498,41 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
outputs:
|
||||
slug: ${{ steps.slug.outputs.slug }}
|
||||
|
||||
# Post the final status as a PR comment. Separated into its own job so
|
||||
# the write perms (pull-requests + issues) are scoped to JUST this job —
|
||||
# the heavy test job above runs with `contents: read` only, so a compromised
|
||||
# transitive dep in `pip install` on a PR-controlled requirements.txt
|
||||
# cannot mutate PRs / issues with the workflow's token.
|
||||
post-result:
|
||||
needs: aimock-e2e
|
||||
if: github.event_name == 'issue_comment' && always() && needs.aimock-e2e.result != 'skipped'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Post result to PR
|
||||
if: github.event_name == 'issue_comment' && always()
|
||||
uses: actions/github-script@v7
|
||||
# Pass dynamic values through env (NOT `${{ ... }}` interpolation into
|
||||
# the script body). Even though the slug is whitelisted upstream, the
|
||||
# env-var pattern is the defensive default: any future additions that
|
||||
# aren't pre-validated cannot accidentally reach script text.
|
||||
env:
|
||||
SLUG: ${{ needs.aimock-e2e.outputs.slug }}
|
||||
JOB_STATUS: ${{ needs.aimock-e2e.result }}
|
||||
with:
|
||||
script: |
|
||||
const status = '${{ job.status }}' === 'success' ? '✅' : '❌';
|
||||
const slug = '${{ steps.slug.outputs.slug }}';
|
||||
const slug = process.env.SLUG || '(unknown)';
|
||||
const jobStatus = process.env.JOB_STATUS;
|
||||
const status = jobStatus === 'success' ? '✅' : '❌';
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `${status} **Aimock E2E Tests** (\`${slug}\`): ${{ job.status }}\n\n[View run](${runUrl})`
|
||||
body: `${status} **Aimock E2E Tests** (\`${slug}\`): ${jobStatus}\n\n[View run](${runUrl})`
|
||||
});
|
||||
|
||||
@@ -119,58 +119,69 @@ jobs:
|
||||
id: build-matrix
|
||||
run: |
|
||||
# Full service config as JSON
|
||||
# Fields: dispatch_name, filter_key, context, image, cache_scope, railway_id, timeout, lfs, build_args, build_args_sha, build_args_branch, dockerfile
|
||||
# Fields: dispatch_name, filter_key, context, image, railway_id, timeout, lfs, build_args, build_args_sha, build_args_branch, dockerfile, health_path
|
||||
# health_path: explicit endpoint the verify step probes. Required
|
||||
# for every service — no fallback. An unscoped fallback could mask
|
||||
# a broken endpoint when an unrelated catch-all/CDN/actuator happens
|
||||
# to 200 at the other path. Misconfigured paths are a config bug to
|
||||
# fix in the matrix, not runtime behavior to hide.
|
||||
ALL_SERVICES='[
|
||||
{"dispatch_name":"shell","filter_key":"shell","context":".","image":"showcase-shell","cache_scope":"shell","railway_id":"40eea0da-6071-4ea8-bdb9-39afb19225ec","timeout":10,"lfs":true,"build_args_sha":"${{ github.sha }}","build_args_branch":"${{ github.ref_name }}","dockerfile":"showcase/shell/Dockerfile"},
|
||||
{"dispatch_name":"langgraph-python","filter_key":"langgraph","context":"showcase/packages/langgraph-python","image":"showcase-langgraph-python","cache_scope":"langgraph","railway_id":"90d03214-4569-41b0-b4c1-6438a8a7b203","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"mastra","filter_key":"mastra","context":"showcase/packages/mastra","image":"showcase-mastra","cache_scope":"mastra","railway_id":"d7979eb7-2405-4aab-ad21-438f4a1b08af","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"crewai-crews","filter_key":"crewai_crews","context":"showcase/packages/crewai-crews","image":"showcase-crewai-crews","cache_scope":"crewai_crews","railway_id":"0e9c284d-8d87-4fcf-9f82-6b704d7e4bd4","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"pydantic-ai","filter_key":"pydantic_ai","context":"showcase/packages/pydantic-ai","image":"showcase-pydantic-ai","cache_scope":"pydantic_ai","railway_id":"0a106173-2282-4887-a994-0ca276a99d69","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"google-adk","filter_key":"google_adk","context":"showcase/packages/google-adk","image":"showcase-google-adk","cache_scope":"google_adk","railway_id":"87f60507-5a3d-4b8a-9e23-2b1de85d939c","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"ag2","filter_key":"ag2","context":"showcase/packages/ag2","image":"showcase-ag2","cache_scope":"ag2","railway_id":"4a37481b-f264-4eb7-a9cd-0a9ebb9ac05c","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"agno","filter_key":"agno","context":"showcase/packages/agno","image":"showcase-agno","cache_scope":"agno","railway_id":"32cab80b-e329-45bd-9c73-c4e1ddc94305","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"llamaindex","filter_key":"llamaindex","context":"showcase/packages/llamaindex","image":"showcase-llamaindex","cache_scope":"llamaindex","railway_id":"285386e8-492d-4cb8-b632-0a7d4607378f","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"langgraph-fastapi","filter_key":"langgraph_fastapi","context":"showcase/packages/langgraph-fastapi","image":"showcase-langgraph-fastapi","cache_scope":"langgraph_fastapi","railway_id":"06cccb5c-59f4-46b5-8adc-7113e77011a4","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"langgraph-typescript","filter_key":"langgraph_typescript","context":"showcase/packages/langgraph-typescript","image":"showcase-langgraph-typescript","cache_scope":"langgraph_typescript","railway_id":"66246d3b-a18e-46f0-be51-5f3ff7a36e5a","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"langroid","filter_key":"langroid","context":"showcase/packages/langroid","image":"showcase-langroid","cache_scope":"langroid","railway_id":"6dd9cb0a-66cc-46f1-972e-7cd74756157d","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"spring-ai","filter_key":"spring_ai","context":"showcase/packages/spring-ai","image":"showcase-spring-ai","cache_scope":"spring_ai","railway_id":"eed5d041-91be-4282-b414-beea00843401","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"strands","filter_key":"strands","context":"showcase/packages/strands","image":"showcase-strands","cache_scope":"strands","railway_id":"92e1cfad-ad53-403f-ab2b-5ab380832232","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"ms-agent-python","filter_key":"ms_agent_python","context":"showcase/packages/ms-agent-python","image":"showcase-ms-agent-python","cache_scope":"ms_agent_python","railway_id":"655db75a-af8d-427d-a4f9-441570ae5003","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"claude-sdk-typescript","filter_key":"claude_sdk_typescript","context":"showcase/packages/claude-sdk-typescript","image":"showcase-claude-sdk-typescript","cache_scope":"claude_sdk_typescript","railway_id":"18a98727-5700-44aa-b497-b60795dbbd6a","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"ms-agent-dotnet","filter_key":"ms_agent_dotnet","context":"showcase/packages/ms-agent-dotnet","image":"showcase-ms-agent-dotnet","cache_scope":"ms_agent_dotnet","railway_id":"beeb2dd6-87a4-4599-aa07-0578f7bd6519","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"claude-sdk-python","filter_key":"claude_sdk_python","context":"showcase/packages/claude-sdk-python","image":"showcase-claude-sdk-python","cache_scope":"claude_sdk_python","railway_id":"b122ab65-9854-4cb2-a68e-b50ff13f7481","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-ag2","filter_key":"starter_ag2","context":"showcase/starters/ag2","image":"showcase-starter-ag2","cache_scope":"starter_ag2","railway_id":"0d7ce4ea-0ebe-4ba6-a408-503f7425c175","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-agno","filter_key":"starter_agno","context":"showcase/starters/agno","image":"showcase-starter-agno","cache_scope":"starter_agno","railway_id":"baf9f0db-1f62-462e-a603-2e1448652473","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-claude-sdk-python","filter_key":"starter_claude_sdk_python","context":"showcase/starters/claude-sdk-python","image":"showcase-starter-claude-sdk-python","cache_scope":"starter_claude_sdk_python","railway_id":"912b480d-ee38-4d8d-ab32-237bee146fed","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-claude-sdk-typescript","filter_key":"starter_claude_sdk_typescript","context":"showcase/starters/claude-sdk-typescript","image":"showcase-starter-claude-sdk-typescript","cache_scope":"starter_claude_sdk_typescript","railway_id":"fa61aabc-aba7-4611-8269-25f5454901ad","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-crewai-crews","filter_key":"starter_crewai_crews","context":"showcase/starters/crewai-crews","image":"showcase-starter-crewai-crews","cache_scope":"starter_crewai_crews","railway_id":"6c8f5514-2295-4d7c-8c95-2687b9e77558","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-google-adk","filter_key":"starter_google_adk","context":"showcase/starters/google-adk","image":"showcase-starter-google-adk","cache_scope":"starter_google_adk","railway_id":"0ae6bb33-b653-41d3-93b4-53482f4e2c31","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-langgraph-fastapi","filter_key":"starter_langgraph_fastapi","context":"showcase/starters/langgraph-fastapi","image":"showcase-starter-langgraph-fastapi","cache_scope":"starter_langgraph_fastapi","railway_id":"dc2070ba-2edb-4def-b7bf-c4c67a5b721b","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-langgraph-python","filter_key":"starter_langgraph_python","context":"showcase/starters/langgraph-python","image":"showcase-starter-langgraph-python","cache_scope":"starter_langgraph_python","railway_id":"58eaea00-00f9-4b66-bd1b-484b2679221b","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-langgraph-typescript","filter_key":"starter_langgraph_typescript","context":"showcase/starters/langgraph-typescript","image":"showcase-starter-langgraph-typescript","cache_scope":"starter_langgraph_typescript","railway_id":"56b73322-1553-402c-9fca-5c710e9d9eb6","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-langroid","filter_key":"starter_langroid","context":"showcase/starters/langroid","image":"showcase-starter-langroid","cache_scope":"starter_langroid","railway_id":"d2da2be5-db1f-48bd-93f9-7fe770d6a863","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-llamaindex","filter_key":"starter_llamaindex","context":"showcase/starters/llamaindex","image":"showcase-starter-llamaindex","cache_scope":"starter_llamaindex","railway_id":"147341c5-12c0-4de1-985a-20af45abea17","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-mastra","filter_key":"starter_mastra","context":"showcase/starters/mastra","image":"showcase-starter-mastra","cache_scope":"starter_mastra","railway_id":"315270a7-7b0e-4a1d-b1ed-319515baf265","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-ms-agent-dotnet","filter_key":"starter_ms_agent_dotnet","context":"showcase/starters/ms-agent-dotnet","image":"showcase-starter-ms-agent-dotnet","cache_scope":"starter_ms_agent_dotnet","railway_id":"986d55f6-4e01-4658-b7c9-cd33cb4df978","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-ms-agent-python","filter_key":"starter_ms_agent_python","context":"showcase/starters/ms-agent-python","image":"showcase-starter-ms-agent-python","cache_scope":"starter_ms_agent_python","railway_id":"bd8e9def-d92f-4c87-95c5-97761c1ea482","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-pydantic-ai","filter_key":"starter_pydantic_ai","context":"showcase/starters/pydantic-ai","image":"showcase-starter-pydantic-ai","cache_scope":"starter_pydantic_ai","railway_id":"f9e01966-ce8d-4e57-a336-315e41d92654","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-spring-ai","filter_key":"starter_spring_ai","context":"showcase/starters/spring-ai","image":"showcase-starter-spring-ai","cache_scope":"starter_spring_ai","railway_id":"3559ece3-7ba3-41ac-b24c-1f780133ec58","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"starter-strands","filter_key":"starter_strands","context":"showcase/starters/strands","image":"showcase-starter-strands","cache_scope":"starter_strands","railway_id":"06db2bb8-e15d-4c6a-97ad-e14777c92d9f","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"aimock","filter_key":"aimock","context":"showcase/aimock","image":"showcase-aimock","cache_scope":"aimock","railway_id":"0fa0435d-8a66-46f0-84fd-e4250b580013","timeout":5,"lfs":false,"build_args":"","dockerfile":""},
|
||||
{"dispatch_name":"shell-dojolike","filter_key":"shell_dojolike","context":"showcase/shell-dojolike","image":"showcase-shell-dojolike","cache_scope":"shell_dojolike","railway_id":"7ad1ece7-2228-49cd-8a78-bddf30322907","timeout":10,"lfs":false,"build_args":"","dockerfile":""}
|
||||
{"dispatch_name":"shell","filter_key":"shell","context":".","image":"showcase-shell","railway_id":"40eea0da-6071-4ea8-bdb9-39afb19225ec","timeout":10,"lfs":true,"build_args_sha":"${{ github.sha }}","build_args_branch":"${{ github.ref_name }}","dockerfile":"showcase/shell/Dockerfile","health_path":"/"},
|
||||
{"dispatch_name":"langgraph-python","filter_key":"langgraph","context":"showcase/packages/langgraph-python","image":"showcase-langgraph-python","railway_id":"90d03214-4569-41b0-b4c1-6438a8a7b203","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"mastra","filter_key":"mastra","context":"showcase/packages/mastra","image":"showcase-mastra","railway_id":"d7979eb7-2405-4aab-ad21-438f4a1b08af","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"crewai-crews","filter_key":"crewai_crews","context":"showcase/packages/crewai-crews","image":"showcase-crewai-crews","railway_id":"0e9c284d-8d87-4fcf-9f82-6b704d7e4bd4","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"pydantic-ai","filter_key":"pydantic_ai","context":"showcase/packages/pydantic-ai","image":"showcase-pydantic-ai","railway_id":"0a106173-2282-4887-a994-0ca276a99d69","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"google-adk","filter_key":"google_adk","context":"showcase/packages/google-adk","image":"showcase-google-adk","railway_id":"87f60507-5a3d-4b8a-9e23-2b1de85d939c","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"ag2","filter_key":"ag2","context":"showcase/packages/ag2","image":"showcase-ag2","railway_id":"4a37481b-f264-4eb7-a9cd-0a9ebb9ac05c","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"agno","filter_key":"agno","context":"showcase/packages/agno","image":"showcase-agno","railway_id":"32cab80b-e329-45bd-9c73-c4e1ddc94305","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"llamaindex","filter_key":"llamaindex","context":"showcase/packages/llamaindex","image":"showcase-llamaindex","railway_id":"285386e8-492d-4cb8-b632-0a7d4607378f","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"langgraph-fastapi","filter_key":"langgraph_fastapi","context":"showcase/packages/langgraph-fastapi","image":"showcase-langgraph-fastapi","railway_id":"06cccb5c-59f4-46b5-8adc-7113e77011a4","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"langgraph-typescript","filter_key":"langgraph_typescript","context":"showcase/packages/langgraph-typescript","image":"showcase-langgraph-typescript","railway_id":"66246d3b-a18e-46f0-be51-5f3ff7a36e5a","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"langroid","filter_key":"langroid","context":"showcase/packages/langroid","image":"showcase-langroid","railway_id":"6dd9cb0a-66cc-46f1-972e-7cd74756157d","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"spring-ai","filter_key":"spring_ai","context":"showcase/packages/spring-ai","image":"showcase-spring-ai","railway_id":"eed5d041-91be-4282-b414-beea00843401","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"strands","filter_key":"strands","context":"showcase/packages/strands","image":"showcase-strands","railway_id":"92e1cfad-ad53-403f-ab2b-5ab380832232","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"ms-agent-python","filter_key":"ms_agent_python","context":"showcase/packages/ms-agent-python","image":"showcase-ms-agent-python","railway_id":"655db75a-af8d-427d-a4f9-441570ae5003","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"claude-sdk-typescript","filter_key":"claude_sdk_typescript","context":"showcase/packages/claude-sdk-typescript","image":"showcase-claude-sdk-typescript","railway_id":"18a98727-5700-44aa-b497-b60795dbbd6a","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"ms-agent-dotnet","filter_key":"ms_agent_dotnet","context":"showcase/packages/ms-agent-dotnet","image":"showcase-ms-agent-dotnet","railway_id":"beeb2dd6-87a4-4599-aa07-0578f7bd6519","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"claude-sdk-python","filter_key":"claude_sdk_python","context":"showcase/packages/claude-sdk-python","image":"showcase-claude-sdk-python","railway_id":"b122ab65-9854-4cb2-a68e-b50ff13f7481","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-ag2","filter_key":"starter_ag2","context":"showcase/starters/ag2","image":"showcase-starter-ag2","railway_id":"0d7ce4ea-0ebe-4ba6-a408-503f7425c175","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-agno","filter_key":"starter_agno","context":"showcase/starters/agno","image":"showcase-starter-agno","railway_id":"baf9f0db-1f62-462e-a603-2e1448652473","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-claude-sdk-python","filter_key":"starter_claude_sdk_python","context":"showcase/starters/claude-sdk-python","image":"showcase-starter-claude-sdk-python","railway_id":"912b480d-ee38-4d8d-ab32-237bee146fed","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-claude-sdk-typescript","filter_key":"starter_claude_sdk_typescript","context":"showcase/starters/claude-sdk-typescript","image":"showcase-starter-claude-sdk-typescript","railway_id":"fa61aabc-aba7-4611-8269-25f5454901ad","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-crewai-crews","filter_key":"starter_crewai_crews","context":"showcase/starters/crewai-crews","image":"showcase-starter-crewai-crews","railway_id":"6c8f5514-2295-4d7c-8c95-2687b9e77558","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-google-adk","filter_key":"starter_google_adk","context":"showcase/starters/google-adk","image":"showcase-starter-google-adk","railway_id":"0ae6bb33-b653-41d3-93b4-53482f4e2c31","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-langgraph-fastapi","filter_key":"starter_langgraph_fastapi","context":"showcase/starters/langgraph-fastapi","image":"showcase-starter-langgraph-fastapi","railway_id":"dc2070ba-2edb-4def-b7bf-c4c67a5b721b","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-langgraph-python","filter_key":"starter_langgraph_python","context":"showcase/starters/langgraph-python","image":"showcase-starter-langgraph-python","railway_id":"58eaea00-00f9-4b66-bd1b-484b2679221b","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-langgraph-typescript","filter_key":"starter_langgraph_typescript","context":"showcase/starters/langgraph-typescript","image":"showcase-starter-langgraph-typescript","railway_id":"56b73322-1553-402c-9fca-5c710e9d9eb6","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-langroid","filter_key":"starter_langroid","context":"showcase/starters/langroid","image":"showcase-starter-langroid","railway_id":"d2da2be5-db1f-48bd-93f9-7fe770d6a863","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-llamaindex","filter_key":"starter_llamaindex","context":"showcase/starters/llamaindex","image":"showcase-starter-llamaindex","railway_id":"147341c5-12c0-4de1-985a-20af45abea17","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-mastra","filter_key":"starter_mastra","context":"showcase/starters/mastra","image":"showcase-starter-mastra","railway_id":"315270a7-7b0e-4a1d-b1ed-319515baf265","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-ms-agent-dotnet","filter_key":"starter_ms_agent_dotnet","context":"showcase/starters/ms-agent-dotnet","image":"showcase-starter-ms-agent-dotnet","railway_id":"986d55f6-4e01-4658-b7c9-cd33cb4df978","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-ms-agent-python","filter_key":"starter_ms_agent_python","context":"showcase/starters/ms-agent-python","image":"showcase-starter-ms-agent-python","railway_id":"bd8e9def-d92f-4c87-95c5-97761c1ea482","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-pydantic-ai","filter_key":"starter_pydantic_ai","context":"showcase/starters/pydantic-ai","image":"showcase-starter-pydantic-ai","railway_id":"f9e01966-ce8d-4e57-a336-315e41d92654","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-spring-ai","filter_key":"starter_spring_ai","context":"showcase/starters/spring-ai","image":"showcase-starter-spring-ai","railway_id":"3559ece3-7ba3-41ac-b24c-1f780133ec58","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"starter-strands","filter_key":"starter_strands","context":"showcase/starters/strands","image":"showcase-starter-strands","railway_id":"06db2bb8-e15d-4c6a-97ad-e14777c92d9f","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
|
||||
{"dispatch_name":"aimock","filter_key":"aimock","context":"showcase/aimock","image":"showcase-aimock","railway_id":"0fa0435d-8a66-46f0-84fd-e4250b580013","timeout":5,"lfs":false,"build_args":"","dockerfile":"","health_path":"/health"},
|
||||
{"dispatch_name":"shell-dojolike","filter_key":"shell_dojolike","context":"showcase/shell-dojolike","image":"showcase-shell-dojolike","railway_id":"7ad1ece7-2228-49cd-8a78-bddf30322907","timeout":10,"lfs":false,"build_args":"","dockerfile":"","health_path":"/"}
|
||||
]'
|
||||
|
||||
DISPATCH="${{ github.event.inputs.service }}"
|
||||
CHANGES='${{ steps.filter.outputs.changes }}'
|
||||
CHANGES="${CHANGES:-[]}"
|
||||
|
||||
# Filter services: on workflow_dispatch, include all (default) or specific service; on push, include only services whose paths changed
|
||||
# Filter services based on three dispatch modes:
|
||||
# dispatch == "all": manual "deploy all" — include every service unconditionally
|
||||
# (paths-filter is unreliable on workflow_dispatch because there is no 'before' SHA,
|
||||
# so we must NOT consult $changes here — doing so silently produces an empty matrix).
|
||||
# dispatch == <specific service>: narrow to that service only (skips paths-filter so
|
||||
# drift-rebuild and manual single-service dispatches work regardless of $changes).
|
||||
# dispatch == "": push event — include services whose filter_key appears in paths-filter CHANGES.
|
||||
MATRIX=$(echo "$ALL_SERVICES" | jq -c --arg dispatch "$DISPATCH" --argjson changes "$CHANGES" '
|
||||
[.[] | select(
|
||||
[.[] | (.filter_key as $fk | select(
|
||||
$dispatch == "all" or
|
||||
$dispatch == .dispatch_name or
|
||||
(.filter_key as $fk | $changes | index($fk) != null)
|
||||
)]
|
||||
($dispatch != "" and $dispatch != "all" and $dispatch == .dispatch_name) or
|
||||
($dispatch == "" and ($changes | index($fk) != null))
|
||||
))]
|
||||
')
|
||||
|
||||
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
|
||||
@@ -196,9 +207,11 @@ jobs:
|
||||
build:
|
||||
needs: [detect-changes, check-lockfile]
|
||||
if: needs.detect-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: ${{ fromJSON(matrix.service.timeout) }}
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -211,8 +224,8 @@ jobs:
|
||||
with:
|
||||
lfs: ${{ matrix.service.lfs }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Setup Depot
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
@@ -251,57 +264,180 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
project: m2kw2wmmcp
|
||||
context: ${{ matrix.service.context }}
|
||||
file: ${{ matrix.service.dockerfile != '' && matrix.service.dockerfile || format('{0}/Dockerfile', matrix.service.context) }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/copilotkit/${{ matrix.service.image }}:latest
|
||||
ghcr.io/copilotkit/${{ matrix.service.image }}:${{ github.sha }}
|
||||
cache-from: type=gha,scope=${{ matrix.service.cache_scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service.cache_scope }}
|
||||
build-args: ${{ steps.build-args.outputs.args }}
|
||||
|
||||
- name: Deploy to Railway
|
||||
id: deploy
|
||||
if: matrix.service.railway_id != ''
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
SERVICE_ID: ${{ matrix.service.railway_id }}
|
||||
ENV_ID: ${{ env.RAILWAY_ENV_ID }}
|
||||
run: |
|
||||
# Capture the current deployment ID BEFORE redeploying so the verify
|
||||
# step can distinguish the fresh deployment from the prior one.
|
||||
PRIOR_RESULT=$(curl -s -H "Authorization: Bearer $RAILWAY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"query\":\"query { deployments(first: 1, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id } } } }\"}" \
|
||||
https://backboard.railway.com/graphql/v2 2>/dev/null)
|
||||
# Fail fast on GraphQL errors — silent failure here would bypass the
|
||||
# stale-deployment guard in the verify step.
|
||||
PRIOR_ERRORS=$(echo "$PRIOR_RESULT" | jq -r '.errors[]?.message // empty')
|
||||
if [ -n "$PRIOR_ERRORS" ]; then
|
||||
echo "::error::Railway prior-deploy query failed: $PRIOR_ERRORS"
|
||||
exit 1
|
||||
fi
|
||||
PRIOR_DEPLOY_ID=$(echo "$PRIOR_RESULT" | jq -r '.data.deployments.edges[0].node.id // empty')
|
||||
echo "Prior deployment ID: ${PRIOR_DEPLOY_ID:-<none>}"
|
||||
echo "prior_deploy_id=$PRIOR_DEPLOY_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# All Railway services are configured to pull :latest from GHCR.
|
||||
# serviceInstanceRedeploy triggers a fresh pull of the configured image.
|
||||
curl -sf -X POST "https://backboard.railway.com/graphql/v2" \
|
||||
-H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \
|
||||
# Check response body for GraphQL errors (HTTP 200 + errors is valid).
|
||||
REDEPLOY_RESULT=$(curl -s -X POST "https://backboard.railway.com/graphql/v2" \
|
||||
-H "Authorization: Bearer $RAILWAY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"mutation { serviceInstanceRedeploy(serviceId: \"${{ matrix.service.railway_id }}\", environmentId: \"${{ env.RAILWAY_ENV_ID }}\") }"}' \
|
||||
&& echo "Deploy triggered for ${{ matrix.service.dispatch_name }}"
|
||||
-d "{\"query\":\"mutation { serviceInstanceRedeploy(serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\") }\"}")
|
||||
REDEPLOY_ERRORS=$(echo "$REDEPLOY_RESULT" | jq -r '.errors[]?.message // empty')
|
||||
if [ -n "$REDEPLOY_ERRORS" ]; then
|
||||
echo "::error::Railway redeploy failed: $REDEPLOY_ERRORS"
|
||||
exit 1
|
||||
fi
|
||||
REDEPLOY_OK=$(echo "$REDEPLOY_RESULT" | jq -r '.data.serviceInstanceRedeploy // false')
|
||||
if [ "$REDEPLOY_OK" != "true" ]; then
|
||||
echo "::error::Railway redeploy did not confirm success: $REDEPLOY_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploy triggered for ${{ matrix.service.dispatch_name }}"
|
||||
|
||||
- name: Verify deploy health
|
||||
if: matrix.service.railway_id != ''
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
SERVICE_ID: ${{ matrix.service.railway_id }}
|
||||
ENV_ID: ${{ env.RAILWAY_ENV_ID }}
|
||||
PRIOR_DEPLOY_ID: ${{ steps.deploy.outputs.prior_deploy_id }}
|
||||
run: |
|
||||
# Wait for Railway to pull and start the new image
|
||||
sleep 30
|
||||
# Fail fast if RAILWAY_TOKEN is not set
|
||||
if [ -z "$RAILWAY_TOKEN" ]; then
|
||||
echo "::error::RAILWAY_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the service domain from Railway API or construct from naming pattern
|
||||
IMAGE="${{ matrix.service.image }}"
|
||||
# Try the standard domain pattern
|
||||
HEALTH_URL="https://${IMAGE}-production.up.railway.app/api/health"
|
||||
echo "Verifying fresh deploy (prior ID: ${PRIOR_DEPLOY_ID:-<none>})..."
|
||||
# 24 attempts * 15s = 360s to accommodate JVM/slow-boot services (spring-ai, mastra)
|
||||
# Require 2 consecutive healthy polls before declaring success — catches
|
||||
# SUCCESS-then-crash (JVM lazy init failure, Python OOM on first request).
|
||||
HEALTHY_STREAK=0
|
||||
REQUIRED_STREAK=2
|
||||
for i in $(seq 1 24); do
|
||||
RESULT=$(curl -s -H "Authorization: Bearer $RAILWAY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"query\":\"query { deployments(first: 1, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id status staticUrl } } } }\"}" \
|
||||
https://backboard.railway.com/graphql/v2 2>/dev/null)
|
||||
|
||||
echo "Checking health: $HEALTH_URL"
|
||||
for i in $(seq 1 6); do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
echo "Attempt $i: HTTP $STATUS"
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Service healthy"
|
||||
exit 0
|
||||
# Surface GraphQL errors and fail fast
|
||||
ERRORS=$(echo "$RESULT" | jq -r '.errors[]?.message // empty')
|
||||
if [ -n "$ERRORS" ]; then
|
||||
echo "::error::Railway API error: $ERRORS"
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
|
||||
DEPLOY_ID=$(echo "$RESULT" | jq -r '.data.deployments.edges[0].node.id')
|
||||
STATUS=$(echo "$RESULT" | jq -r '.data.deployments.edges[0].node.status')
|
||||
DOMAIN=$(echo "$RESULT" | jq -r '.data.deployments.edges[0].node.staticUrl')
|
||||
echo "Attempt $i: deploy=$DEPLOY_ID status=$STATUS domain=$DOMAIN streak=$HEALTHY_STREAK"
|
||||
|
||||
# Skip stale deployments: if we see the prior deployment, the fresh
|
||||
# one hasn't appeared yet — wait without declaring success or failure.
|
||||
if [ -n "$PRIOR_DEPLOY_ID" ] && [ "$DEPLOY_ID" = "$PRIOR_DEPLOY_ID" ]; then
|
||||
echo " (prior deployment still latest — waiting for fresh one)"
|
||||
HEALTHY_STREAK=0
|
||||
sleep 15
|
||||
continue
|
||||
fi
|
||||
|
||||
# Fail on any terminal failure status. Railway's DeploymentStatus enum
|
||||
# has no CANCELLED value; the real terminal failures are below.
|
||||
case "$STATUS" in
|
||||
CRASHED|FAILED|REMOVED|SKIPPED)
|
||||
echo "::error::Service ${{ matrix.service.dispatch_name }} deploy status: $STATUS"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$STATUS" = "SUCCESS" ] && [ -n "$DOMAIN" ] && [ "$DOMAIN" != "null" ]; then
|
||||
# Probe the service-specific health_path. Hit a real endpoint
|
||||
# rather than root: many services are API-only backends that 404
|
||||
# at /, so a 404 at root can't distinguish "dead" from
|
||||
# "alive-but-no-index-route".
|
||||
# No fallback — an unscoped fallback could mask a broken endpoint
|
||||
# when an unrelated catch-all/CDN/actuator happens to 200 at the
|
||||
# other path. Misconfigured paths are a config bug to fix in the
|
||||
# matrix, not runtime behavior to hide.
|
||||
HEALTH_PATH="${{ matrix.service.health_path }}"
|
||||
if [ -z "$HEALTH_PATH" ]; then
|
||||
echo "::error::health_path not configured for service ${{ matrix.service.dispatch_name }} in ALL_SERVICES"
|
||||
exit 1
|
||||
fi
|
||||
HEALTH_URL="https://${DOMAIN}${HEALTH_PATH}"
|
||||
# Services like `shell` and `shell-dojolike` probe `/` (a Next.js
|
||||
# homepage), which can transiently 5xx or bounce through a 301
|
||||
# chain during cold starts — a single bad response would reset
|
||||
# HEALTHY_STREAK and throw away progress. Retry the probe up to
|
||||
# 3 times within this iteration before treating it as a genuine
|
||||
# non-200; legitimate failures still fail because all 3 tries
|
||||
# must return non-200.
|
||||
HTTP_CODE="000"
|
||||
for probe_try in 1 2 3; do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$probe_try" -lt 3 ]; then
|
||||
echo "HTTP check: $HEALTH_URL → $HTTP_CODE (retry $probe_try/3)"
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
echo "HTTP check: $HEALTH_URL → $HTTP_CODE"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
HEALTHY_STREAK=$((HEALTHY_STREAK + 1))
|
||||
if [ "$HEALTHY_STREAK" -ge "$REQUIRED_STREAK" ]; then
|
||||
echo "Service healthy ($HEALTHY_STREAK consecutive 200s)"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
# Reset streak if we had a partial streak and then a non-200
|
||||
HEALTHY_STREAK=0
|
||||
fi
|
||||
# SUCCESS but not yet responding — app process still booting
|
||||
else
|
||||
HEALTHY_STREAK=0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
echo "Service did not return 200 after 90s (may still be starting with sleep-on-idle)"
|
||||
# Don't fail the job — sleep-on-idle services may take longer to wake
|
||||
# But log it clearly for visibility
|
||||
echo "::error::Service ${{ matrix.service.dispatch_name }} did not become healthy within 360s"
|
||||
# Fail the job so Slack alerts fire — silent deploy failures
|
||||
# should never report green. Railway is on the Pro tier and does
|
||||
# not sleep on idle, so a persistent timeout is a real failure.
|
||||
exit 1
|
||||
|
||||
notify:
|
||||
needs: [detect-changes, check-lockfile, build]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
@@ -315,7 +451,136 @@ jobs:
|
||||
echo "services=$SERVICES" >> $GITHUB_OUTPUT
|
||||
echo "count=$COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
# Restore previous run's pass/fail status from cache so we can emit
|
||||
# a red→green transition alert on recovery. Mirrors the per-service
|
||||
# transition pattern in showcase_smoke-monitor.yml. The restore-keys
|
||||
# prefix gives us the most recently saved state regardless of which
|
||||
# run_id wrote it. Cache TTL is ~7 days, which is fine — deploys run
|
||||
# more frequently than that.
|
||||
- name: Restore deploy state from cache
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: showcase-deploy-state.json
|
||||
key: showcase-deploy-state-impossible-match
|
||||
restore-keys: |
|
||||
showcase-deploy-state-
|
||||
|
||||
- name: Initialize state if missing
|
||||
run: |
|
||||
# First-ever run (or cache eviction): assume "ok" so we don't
|
||||
# emit a false recovery alert on the first green run after
|
||||
# deploying this workflow change.
|
||||
if [ ! -f showcase-deploy-state.json ]; then
|
||||
echo '{"lastStatus":"ok","lastFailureAt":""}' > showcase-deploy-state.json
|
||||
fi
|
||||
|
||||
- name: Compute per-leg build results
|
||||
id: legs
|
||||
if: always() && needs.build.result == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# The build job is a matrix with fail-fast: false, so
|
||||
# `needs.build.result == 'failure'` can mean "ALL legs failed" or
|
||||
# "one leg failed and N others succeeded". Without per-leg detail
|
||||
# the notify step would say "FAILED — N service(s) targeted",
|
||||
# falsely implying every leg failed when only one did.
|
||||
#
|
||||
# Query the Actions API to list every `build (<leg>)` job in this
|
||||
# run and bucket by conclusion. Best-effort: if the API lookup
|
||||
# fails (auth, transient 5xx), the notify step falls back to the
|
||||
# softer "1+ of N failed" wording which doesn't lie either way.
|
||||
set +e
|
||||
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ] || [ -z "$jobs_json" ]; then
|
||||
echo "::warning::Could not fetch per-leg job results (rc=$rc); notify will use softer wording"
|
||||
echo "have_detail=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Matrix legs are named "build (<leg-name>)" by default. We extract
|
||||
# the leg name from parentheses; anything that doesn't match is
|
||||
# ignored. jq -r emits one SLUG<TAB>CONCLUSION pair per line so
|
||||
# we can bucket in shell without re-parsing JSON.
|
||||
# Single-pass pipeline: iterate .jobs directly and emit on the
|
||||
# matching branch — avoids the map-then-flatten indirection that
|
||||
# made the previous version harder to read.
|
||||
pairs=$(printf '%s' "$jobs_json" | jq -r '
|
||||
.jobs[]?
|
||||
| select(.name | test("^build \\(.+\\)$"))
|
||||
| "\(.name | capture("^build \\((?<s>.+)\\)$").s)\t\(.conclusion // "unknown")"
|
||||
' 2>/dev/null)
|
||||
if [ -z "$pairs" ]; then
|
||||
echo "::warning::No matrix-leg jobs found in run; notify will use softer wording"
|
||||
echo "have_detail=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
succeeded=""
|
||||
failed=""
|
||||
while IFS=$'\t' read -r slug conclusion; do
|
||||
[ -z "$slug" ] && continue
|
||||
case "$conclusion" in
|
||||
success) succeeded="$succeeded $slug" ;;
|
||||
failure|cancelled|timed_out) failed="$failed $slug" ;;
|
||||
*) ;; # skipped/neutral/unknown — exclude from both lists
|
||||
esac
|
||||
done <<< "$pairs"
|
||||
|
||||
succeeded=$(echo "$succeeded" | xargs)
|
||||
failed=$(echo "$failed" | xargs)
|
||||
succeeded_count=$(echo "$succeeded" | wc -w | xargs)
|
||||
failed_count=$(echo "$failed" | wc -w | xargs)
|
||||
|
||||
# Cap list length for Slack readability (same 200-char cap as
|
||||
# services_list in the payload step). `cut -c1-200` used to
|
||||
# truncate mid-slug, producing garbage like `starter-claude-sdk-pyth`;
|
||||
# drop whole comma-separated entries instead and append a single
|
||||
# ellipsis so truncation is legible to humans. Keeps the 200-char
|
||||
# budget so Slack single-line formatting still fits.
|
||||
truncate_csv() {
|
||||
local budget=$1 input=$2 out="" item proposed
|
||||
# Split on single space (entries come from shell word-split),
|
||||
# then render as comma-separated.
|
||||
for item in $input; do
|
||||
if [ -z "$out" ]; then
|
||||
proposed="$item"
|
||||
else
|
||||
proposed="$out, $item"
|
||||
fi
|
||||
if [ ${#proposed} -le "$budget" ]; then
|
||||
out="$proposed"
|
||||
else
|
||||
if [ -z "$out" ]; then
|
||||
# First item already exceeds the budget — fall back to
|
||||
# a hard cut so we at least emit something.
|
||||
out=$(printf '%s' "$item" | cut -c1-"$budget")
|
||||
else
|
||||
out="$out, …"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
succeeded_list=$(truncate_csv 200 "$succeeded")
|
||||
failed_list=$(truncate_csv 200 "$failed")
|
||||
|
||||
{
|
||||
echo "have_detail=true"
|
||||
echo "succeeded_count=$succeeded_count"
|
||||
echo "failed_count=$failed_count"
|
||||
echo "succeeded_list=$succeeded_list"
|
||||
echo "failed_list=$failed_list"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Slack payload
|
||||
id: payload
|
||||
if: always()
|
||||
run: |
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
@@ -323,24 +588,190 @@ jobs:
|
||||
LOCKFILE="${{ needs.check-lockfile.result }}"
|
||||
BUILD="${{ needs.build.result }}"
|
||||
COUNT="${{ steps.summary.outputs.count }}"
|
||||
SERVICES=$(echo "${{ steps.summary.outputs.services }}" | cut -c1-200)
|
||||
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [ "$DETECT" = "failure" ] || [ "$LOCKFILE" = "failure" ]; then
|
||||
MSG=":x: *Showcase deploy*: FAILED (pre-build check)"
|
||||
elif [ "$BUILD" = "success" ]; then
|
||||
MSG=":white_check_mark: *Showcase deploy*: ${COUNT} service(s) deployed to Railway (${SERVICES})"
|
||||
elif [ "$BUILD" = "skipped" ] && [ "$DETECT" = "success" ]; then
|
||||
MSG=":white_check_mark: *Showcase deploy*: no changes detected, nothing to deploy"
|
||||
else
|
||||
MSG=":x: *Showcase deploy*: FAILED — ${COUNT} service(s) targeted (${SERVICES})"
|
||||
# Read previous run's status (restored from cache). Mirrors the
|
||||
# smoke-monitor transition pattern: on red→green we post a
|
||||
# recovery message; green→green stays silent.
|
||||
PREV_STATUS=$(jq -r '.lastStatus // "ok"' showcase-deploy-state.json)
|
||||
PREV_FAILURE_AT=$(jq -r '.lastFailureAt // ""' showcase-deploy-state.json)
|
||||
|
||||
# See truncate_csv in steps.legs for the truncation contract —
|
||||
# drop whole entries, not mid-slug characters, then append `…`
|
||||
# if the list exceeds the budget. Duplicate the helper inline
|
||||
# because YAML run blocks don't share shell functions.
|
||||
truncate_csv() {
|
||||
local budget=$1 input=$2 out="" item proposed
|
||||
# Input is comma-separated (jq join(", ")) — normalise to
|
||||
# space separation for the shell word-split below.
|
||||
local ws="${input//,/ }"
|
||||
for item in $ws; do
|
||||
if [ -z "$out" ]; then
|
||||
proposed="$item"
|
||||
else
|
||||
proposed="$out, $item"
|
||||
fi
|
||||
if [ ${#proposed} -le "$budget" ]; then
|
||||
out="$proposed"
|
||||
else
|
||||
if [ -z "$out" ]; then
|
||||
out=$(printf '%s' "$item" | cut -c1-"$budget")
|
||||
else
|
||||
out="$out, …"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
SERVICES=$(truncate_csv 200 "${{ steps.summary.outputs.services }}")
|
||||
|
||||
# Classify the run outcome into one of:
|
||||
# failure → update state→failure, post red alert
|
||||
# success → update state→ok, post recovery iff PREV_STATUS=failure
|
||||
# skip → no state change, no post (indeterminate)
|
||||
OUTCOME=""
|
||||
MSG=""
|
||||
|
||||
# Cancellations can come from concurrency group supersession (rapid
|
||||
# pushes), manual cancel via the UI, or an upstream-failure cascade.
|
||||
# We handle pre-build and mid-build cancellations differently:
|
||||
#
|
||||
# Pre-build stages (detect-changes, check-lockfile): no build work
|
||||
# has happened yet. Safe to stay silent — any newer run (or the
|
||||
# manual re-trigger) will redo all work from scratch. Don't touch
|
||||
# state either way — indeterminate outcome.
|
||||
if [ "$DETECT" = "cancelled" ] || [ "$LOCKFILE" = "cancelled" ]; then
|
||||
echo "Pre-build stage cancelled — newer run will redo all work, skipping Slack notification"
|
||||
echo "should_post=false" >> "$GITHUB_OUTPUT"
|
||||
echo "update_state=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -n --arg text "$MSG | <$URL|View run>" '{text: $text}' > /tmp/slack-payload.json
|
||||
# Mid-build cancellation: the build job is a matrix over many
|
||||
# services with fail-fast: false. If ANY leg was cancelled while
|
||||
# OTHERS completed, the aggregate rolls up to 'cancelled'. Silently
|
||||
# skipping would hide those already-run legs. Additionally, a newer
|
||||
# push's detect-changes is path-scoped and may not target the same
|
||||
# services, so the cancelled leg may never be re-verified. Post a
|
||||
# distinct muted message so humans can spot the anomaly instead of
|
||||
# assuming green. Don't touch state — indeterminate outcome.
|
||||
if [ "$BUILD" = "cancelled" ]; then
|
||||
MSG=":information_source: *Showcase deploy*: cancelled mid-matrix — newer run (or manual retrigger) continuing; inspect if issues persist"
|
||||
jq -n --arg text "$MSG | <$URL|View run>" '{text: $text}' > /tmp/slack-payload.json
|
||||
echo "should_post=true" >> "$GITHUB_OUTPUT"
|
||||
echo "update_state=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Classify the terminal outcome.
|
||||
if [ "$DETECT" = "failure" ] || [ "$LOCKFILE" = "failure" ]; then
|
||||
OUTCOME="failure"
|
||||
MSG=":x: *Showcase deploy*: FAILED (pre-build check)"
|
||||
elif [ "$BUILD" = "success" ]; then
|
||||
OUTCOME="success"
|
||||
elif [ "$BUILD" = "skipped" ] && [ "$DETECT" = "success" ]; then
|
||||
# No service changes matched the paths filter — run proved
|
||||
# nothing either way. Don't flip state.
|
||||
OUTCOME="skip"
|
||||
elif [ "$BUILD" = "failure" ] || [ -n "$BUILD" ]; then
|
||||
# Build failed. Distinguish full failure (every leg failed) from
|
||||
# partial failure (some legs passed, some failed). The previous
|
||||
# wording said "FAILED — N service(s) targeted" which implied
|
||||
# ALL N services failed when in fact only a subset did. Use the
|
||||
# per-leg detail from steps.legs when available; fall back to a
|
||||
# softer "1+ of N failed" phrasing if the API lookup didn't
|
||||
# return detail (see the "Compute per-leg build results" step).
|
||||
OUTCOME="failure"
|
||||
HAVE_DETAIL="${{ steps.legs.outputs.have_detail }}"
|
||||
FAILED_COUNT="${{ steps.legs.outputs.failed_count }}"
|
||||
SUCCEEDED_COUNT="${{ steps.legs.outputs.succeeded_count }}"
|
||||
FAILED_LIST="${{ steps.legs.outputs.failed_list }}"
|
||||
SUCCEEDED_LIST="${{ steps.legs.outputs.succeeded_list }}"
|
||||
if [ "$HAVE_DETAIL" = "true" ] && [ -n "$FAILED_COUNT" ] && [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
if [ -n "$SUCCEEDED_COUNT" ] && [ "$SUCCEEDED_COUNT" -gt 0 ]; then
|
||||
# Partial failure — some legs passed, some failed.
|
||||
MSG=":x: *Showcase deploy*: ${FAILED_COUNT}/${COUNT} service(s) failed (${FAILED_LIST}) — ${SUCCEEDED_LIST} ok"
|
||||
else
|
||||
# All legs failed.
|
||||
MSG=":x: *Showcase deploy*: FAILED — ${COUNT} service(s) targeted (${SERVICES})"
|
||||
fi
|
||||
else
|
||||
# Softer fallback when per-leg detail isn't available — avoids
|
||||
# the lie that "N of N failed" when we don't actually know.
|
||||
MSG=":x: *Showcase deploy*: 1+ of ${COUNT} service(s) failed (${SERVICES} targeted)"
|
||||
fi
|
||||
else
|
||||
# BUILD is empty/unknown + no other signal — no-op.
|
||||
OUTCOME="skip"
|
||||
fi
|
||||
|
||||
# Policy: #oss-alerts should only surface actionable state —
|
||||
# failures and red→green transitions. green→green is silent
|
||||
# (suppresses per-run success noise, especially during bulk drift
|
||||
# rebuilds which fan out one showcase_deploy.yml run per service
|
||||
# up to ~18). The top-line ":package: Image drift detected — N
|
||||
# rebuilds triggered" posted by showcase_smoke-monitor.yml is the
|
||||
# aggregate success signal for bulk rebuilds; ad-hoc single-service
|
||||
# pushes/dispatches stay quiet on success.
|
||||
case "$OUTCOME" in
|
||||
failure)
|
||||
jq -n --arg text "$MSG | <$URL|View run>" '{text: $text}' > /tmp/slack-payload.json
|
||||
echo "should_post=true" >> "$GITHUB_OUTPUT"
|
||||
echo "update_state=true" >> "$GITHUB_OUTPUT"
|
||||
echo "new_status=failure" >> "$GITHUB_OUTPUT"
|
||||
echo "new_failure_at=$NOW" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
success)
|
||||
if [ "$PREV_STATUS" = "failure" ]; then
|
||||
# Red→green transition — emit recovery. Wording mirrors
|
||||
# the smoke-monitor per-service format.
|
||||
RECOVERY_MSG=":white_check_mark: *Showcase deploy*: recovered (was down since ${PREV_FAILURE_AT})"
|
||||
jq -n --arg text "$RECOVERY_MSG | <$URL|View run>" '{text: $text}' > /tmp/slack-payload.json
|
||||
echo "should_post=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Build succeeded (green→green) — suppressing per-run success notification (policy: actionable-only)"
|
||||
echo "should_post=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "update_state=true" >> "$GITHUB_OUTPUT"
|
||||
echo "new_status=ok" >> "$GITHUB_OUTPUT"
|
||||
echo "new_failure_at=" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
skip)
|
||||
echo "Indeterminate / no-changes run — suppressing notification (policy: actionable-only)"
|
||||
echo "should_post=false" >> "$GITHUB_OUTPUT"
|
||||
echo "update_state=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Post to Slack
|
||||
if: always()
|
||||
if: always() && steps.payload.outputs.should_post == 'true' && env.SLACK_WEBHOOK_OSS_ALERTS != ''
|
||||
env:
|
||||
SLACK_WEBHOOK_OSS_ALERTS: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/slack-payload.json
|
||||
|
||||
# Persist the transition state for the NEXT run to read. We save
|
||||
# iff the payload step classified this run as a terminal outcome
|
||||
# (failure or success); indeterminate runs (cancelled, no-changes)
|
||||
# leave the prior state untouched so streaks aren't broken.
|
||||
- name: Update deploy state file
|
||||
if: always() && steps.payload.outputs.update_state == 'true'
|
||||
run: |
|
||||
NEW_STATUS='${{ steps.payload.outputs.new_status }}'
|
||||
NEW_FAILURE_AT='${{ steps.payload.outputs.new_failure_at }}'
|
||||
jq -n \
|
||||
--arg status "$NEW_STATUS" \
|
||||
--arg failureAt "$NEW_FAILURE_AT" \
|
||||
'{lastStatus: $status, lastFailureAt: $failureAt}' \
|
||||
> showcase-deploy-state.json
|
||||
|
||||
- name: Save deploy state to cache
|
||||
if: always() && steps.payload.outputs.update_state == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: showcase-deploy-state.json
|
||||
key: showcase-deploy-state-${{ github.run_id }}
|
||||
|
||||
@@ -59,15 +59,13 @@ jobs:
|
||||
elif [ $EXIT_CODE -eq 3 ]; then
|
||||
echo "Has review items + clean transforms"
|
||||
echo "action=push_and_pr" >> "$GITHUB_OUTPUT"
|
||||
if [ -f review-items.txt ]; then
|
||||
REVIEW_ITEMS=$(cat review-items.txt)
|
||||
else
|
||||
# The sync script writes review-items.txt and emits
|
||||
# review_items_file=<abs-path> directly to GITHUB_OUTPUT.
|
||||
# Verify the file actually exists so downstream steps don't
|
||||
# silently read an empty path.
|
||||
if [ ! -f review-items.txt ]; then
|
||||
echo "::warning::review-items.txt not found despite exit code 3 — sync script may have a bug"
|
||||
REVIEW_ITEMS="(review-items.txt not found — check sync script output)"
|
||||
fi
|
||||
# JSON-escape review items for embedding in Slack payload
|
||||
REVIEW_ITEMS_JSON=$(echo "$REVIEW_ITEMS" | jq -Rs '.' | sed 's/^"//;s/"$//')
|
||||
echo "review_items_json=${REVIEW_ITEMS_JSON}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Sync failed with exit code $EXIT_CODE"
|
||||
exit 1
|
||||
@@ -82,82 +80,384 @@ jobs:
|
||||
app-id: 1108748
|
||||
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
|
||||
|
||||
# Create PR and auto-merge via devops bot (bypasses branch protection)
|
||||
- name: Create and merge PR for clean transforms
|
||||
# Create PR. For action=auto_push (no review items) the PR is
|
||||
# auto-merged via the devops bot (bypasses branch protection). For
|
||||
# action=push_and_pr the sync script has already written best-effort
|
||||
# 3-way merged content to disk — this step commits it and opens a PR
|
||||
# tagged [NEEDS REVIEW]. Auto-merge is DISABLED for needs-review PRs
|
||||
# so a human reconciles any upstream-wins overrides.
|
||||
- name: Create PR for docs sync
|
||||
id: push
|
||||
if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
|
||||
ACTION: ${{ steps.sync.outputs.action }}
|
||||
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
|
||||
run: |
|
||||
# Trap-based cleanup so temp files are removed even on signal kill.
|
||||
CLEANUP_FILES=()
|
||||
cleanup() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
[ -n "$f" ] && [ -e "$f" ] && rm -f "$f" || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
git config user.name "copilotkit-devops-bot[bot]"
|
||||
git config user.email "copilotkit-devops-bot[bot]@users.noreply.github.com"
|
||||
|
||||
SHORT_SHA=$(git rev-parse --short origin/main)
|
||||
BRANCH="docs-sync/auto/${SHORT_SHA}-$(date +%s)"
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
# Dedupe against open needs-review PRs. If one already exists,
|
||||
# skip PR creation and let the Slack alert point at the existing
|
||||
# one (simpler than re-pushing to its branch).
|
||||
#
|
||||
# NOTE: `gh pr list --search "head:..."` is NOT supported —
|
||||
# GitHub's PR search syntax has no `head:` qualifier, so that
|
||||
# query always returns empty. Use jq on the full list instead
|
||||
# to filter by headRefName prefix client-side.
|
||||
# Fail the step on gh API failure rather than swallowing it —
|
||||
# `|| echo ""` would silently treat a transient 5xx as "no
|
||||
# existing PR" and open a duplicate. Retry once with backoff
|
||||
# to absorb blips; if both attempts fail, exit non-zero.
|
||||
gh_pr_list_existing() {
|
||||
gh pr list \
|
||||
--state open \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--json number,url,headRefName \
|
||||
--jq '[.[] | select(.headRefName | startswith("docs-sync/needs-review/"))] | .[0].url'
|
||||
}
|
||||
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
|
||||
echo "::warning::gh pr list failed on first attempt — retrying after 5s"
|
||||
cat gh-err.txt || true
|
||||
sleep 5
|
||||
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
|
||||
echo "::error::gh pr list failed twice — aborting to avoid duplicate PR creation"
|
||||
cat gh-err.txt || true
|
||||
rm -f gh-err.txt
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f gh-err.txt
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "Open needs-review PR already exists: $EXISTING_PR"
|
||||
echo "Skipping new PR — Slack alert will point at the existing one."
|
||||
echo "files_changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
echo "existing_pr=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="docs-sync/needs-review/${SHORT_SHA}-$(date +%s)"
|
||||
COMMIT_SUBJECT="chore: docs sync from main — needs review ($(date +%Y-%m-%d))"
|
||||
else
|
||||
BRANCH="docs-sync/auto/${SHORT_SHA}-$(date +%s)"
|
||||
COMMIT_SUBJECT="chore: auto-sync docs from main ($(date +%Y-%m-%d))"
|
||||
fi
|
||||
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
# For push_and_pr: apply the conflict manifest FIRST (upstream-wins
|
||||
# content for conflicted files, written ONLY to the PR branch so
|
||||
# the main branch worktree stays as-is and future sync runs still
|
||||
# flag those files for review until this PR is merged).
|
||||
#
|
||||
# CRITICAL: This MUST run BEFORE `git add` below. `git add` stages
|
||||
# the current working tree, so any manifest files written after
|
||||
# that point would be left un-staged and never make it into the
|
||||
# commit.
|
||||
if [ "$ACTION" = "push_and_pr" ] && [ -f conflict-manifest.json ]; then
|
||||
CLEANUP_FILES+=("conflict-manifest.json")
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const manifest = JSON.parse(fs.readFileSync("conflict-manifest.json", "utf-8"));
|
||||
for (const entry of manifest) {
|
||||
const target = path.resolve(entry.showcasePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, entry.content);
|
||||
console.log("Applied upstream-wins to PR branch: " + entry.showcasePath);
|
||||
}
|
||||
'
|
||||
fi
|
||||
|
||||
# Stage AFTER manifest has been applied so conflicted files land
|
||||
# in the commit.
|
||||
git add showcase/shell/src/content/ showcase/shell/.docs-sync-sha
|
||||
CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$CHANGED" = "0" ]; then
|
||||
echo "Nothing to commit"
|
||||
echo "Nothing to commit — no PR will be opened (deliberate)"
|
||||
echo "files_changed=0" >> "$GITHUB_OUTPUT"
|
||||
# Explicit signal: no PR opened, and this is the intended outcome
|
||||
# (not an error path). Downstream alerts key on this.
|
||||
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
|
||||
# Set needs_review explicitly so downstream `needs_review != 'true'`
|
||||
# gates (notify-auto-sync) don't misfire on this deliberate
|
||||
# no-op. Value depends on why we got here: push_and_pr path =
|
||||
# review still pending; auto_push path = no review needed.
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "needs_review=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit --no-verify -m "chore: auto-sync docs from main ($(date +%Y-%m-%d))"
|
||||
git commit --no-verify -m "$COMMIT_SUBJECT"
|
||||
|
||||
# Override git credential to use bot token (checkout configured GITHUB_TOKEN)
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--title "chore: auto-sync docs from main (${SHORT_SHA})" \
|
||||
--body "Automated docs sync. Clean transforms only." \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH")
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
# Build the PR body from review-items.txt plus a clear callout.
|
||||
REVIEW_ITEMS_CONTENT="(no review-items file produced)"
|
||||
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
|
||||
REVIEW_ITEMS_CONTENT=$(cat "$REVIEW_ITEMS_FILE")
|
||||
fi
|
||||
PR_BODY_FILE=$(mktemp)
|
||||
CLEANUP_FILES+=("$PR_BODY_FILE")
|
||||
{
|
||||
printf '%s\n' ':warning: **Docs sync — MANUAL REVIEW REQUIRED**'
|
||||
printf '\n'
|
||||
printf '%s\n' 'This PR was auto-opened because the docs-sync script detected'
|
||||
printf '%s\n' 'showcase-local modifications overlapping with upstream changes.'
|
||||
printf '\n'
|
||||
printf '%s\n' 'The script attempted a best-effort 3-way merge:'
|
||||
printf '\n'
|
||||
printf '%s\n' '- Where `git merge-file` produced a clean merge, the merged content was written.'
|
||||
printf '%s\n' '- Where `git merge-file` produced conflict markers, **upstream content was written as-is** and showcase-local modifications were overridden. **Manual review required.**'
|
||||
printf '\n'
|
||||
printf '%s\n' '### Review items'
|
||||
printf '\n'
|
||||
printf '%s\n' '```'
|
||||
# printf '%s\n' avoids running command substitution / backticks
|
||||
# embedded in review-items content (cat "$REVIEW_ITEMS_FILE"
|
||||
# would also work; printf is equivalent here since we already
|
||||
# captured the content).
|
||||
printf '%s\n' "$REVIEW_ITEMS_CONTENT"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
printf '%s\n' '### Source'
|
||||
printf '\n'
|
||||
printf '%s\n' "- Upstream ref: [\`${SHORT_SHA}\`](https://github.com/${{ github.repository }}/commit/${SHORT_SHA})"
|
||||
printf '%s\n' "- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
printf '\n'
|
||||
printf '%s\n' '**Review before merging.** Auto-merge is intentionally disabled'
|
||||
printf '%s\n' 'for `needs-review` PRs — confirm the upstream-wins sections'
|
||||
printf '%s\n' 'preserve any intentional showcase-local divergence you want to'
|
||||
printf '%s\n' 'keep, then merge manually.'
|
||||
} > "$PR_BODY_FILE"
|
||||
|
||||
echo "Created PR: $PR_URL"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
PR_URL=$(gh pr create \
|
||||
--title "docs-sync(needs-review): sync from main (${SHORT_SHA}) [NEEDS REVIEW]" \
|
||||
--body-file "$PR_BODY_FILE" \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH")
|
||||
|
||||
gh pr merge "$PR_URL" --merge
|
||||
echo "Created NEEDS-REVIEW PR: $PR_URL"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
# Only set after URL captured — error paths leave this unset so
|
||||
# downstream alerts fall through to failure().
|
||||
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
# Intentionally no `gh pr merge` — human must review & merge.
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--title "chore: auto-sync docs from main (${SHORT_SHA})" \
|
||||
--body "Automated docs sync. Clean transforms / clean 3-way merges only." \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH")
|
||||
|
||||
echo "Created PR: $PR_URL"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
gh pr merge "$PR_URL" --merge
|
||||
fi
|
||||
|
||||
# Build all Slack payloads via jq into tmpfiles. This guarantees any
|
||||
# review-item filename containing ", \, or control chars is safely
|
||||
# JSON-escaped (never string-interpolated into a JSON literal).
|
||||
- name: Build Slack payloads
|
||||
id: payloads
|
||||
if: always()
|
||||
env:
|
||||
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
PR_URL: ${{ steps.push.outputs.pr_url }}
|
||||
FILES_CHANGED: ${{ steps.push.outputs.files_changed }}
|
||||
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
|
||||
SYNC_OUTCOME: ${{ steps.sync.outcome }}
|
||||
BOT_TOKEN_OUTCOME: ${{ steps.bot-token.outcome }}
|
||||
PUSH_OUTCOME: ${{ steps.push.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p slack-payloads
|
||||
|
||||
# auto-sync success (PR merged)
|
||||
jq -n \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg files "${FILES_CHANGED:-?}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":arrows_counterclockwise: *Docs sync*: auto-merged " + $files + " file(s)\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/auto-sync.json
|
||||
|
||||
# merge failed (PR exists but gh pr merge failed)
|
||||
jq -n \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: PR created but auto-merge FAILED — needs manual merge\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/merge-failed.json
|
||||
|
||||
# review-needed payloads: read items from file, let jq handle escaping
|
||||
REVIEW_ITEMS=""
|
||||
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
|
||||
REVIEW_ITEMS=$(cat "$REVIEW_ITEMS_FILE")
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: auto-opened *NEEDS REVIEW* PR (best-effort 3-way merge; upstream-wins where conflicts) — human must review + merge\n```" + $items + "```\nReview: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-with-pr.json
|
||||
|
||||
# Collision path: new review items flagged but an existing open
|
||||
# needs-review PR already covers the territory; we did NOT open a
|
||||
# new PR. Point reviewers at the existing one.
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: new review items detected, but an open *NEEDS REVIEW* PR already exists — skipped new PR creation to avoid collision. Please resolve the existing PR.\n```" + $items + "```\nExisting PR: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-existing-pr.json
|
||||
|
||||
# Fallback path: review items flagged but no PR opened (e.g. 3-way
|
||||
# merge produced bit-for-bit identical content to what's already on
|
||||
# disk so nothing to commit). Keep an alert so it's visible.
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: review items flagged but produced no diff (no PR opened)\n```" + $items + "```\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-no-pr.json
|
||||
|
||||
# failure alert
|
||||
FAILED_STEP="unknown"
|
||||
if [ "${SYNC_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="sync-docs script"
|
||||
elif [ "${BOT_TOKEN_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="bot token generation (check DEVOPS_BOT_PRIVATE_KEY secret)"
|
||||
elif [ "${PUSH_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="push/PR creation"
|
||||
fi
|
||||
jq -n \
|
||||
--arg failed_step "$FAILED_STEP" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":x: *Docs sync*: workflow failed\n*Failed step:* " + $failed_step + " | <" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/failure.json
|
||||
|
||||
- name: Notify Slack (auto-sync)
|
||||
if: always() && steps.push.outcome == 'success' && steps.push.outputs.files_changed != '0'
|
||||
id: notify-auto-sync
|
||||
if: always() && steps.push.outcome == 'success' && steps.push.outputs.files_changed != '0' && steps.push.outputs.needs_review != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":arrows_counterclockwise: *Docs sync*: auto-merged ${{ steps.push.outputs.files_changed || '?' }} file(s)\n${{ steps.push.outputs.pr_url || '' }}\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: slack-payloads/auto-sync.json
|
||||
|
||||
- name: Notify Slack (merge failed)
|
||||
if: failure() && steps.push.outputs.pr_url != ''
|
||||
id: notify-merge-failed
|
||||
if: failure() && steps.push.outputs.pr_opened == 'true' && steps.push.outputs.needs_review != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":warning: *Docs sync*: PR created but auto-merge FAILED — needs manual merge\n${{ steps.push.outputs.pr_url }}\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: slack-payloads/merge-failed.json
|
||||
|
||||
# Review items = files the sync script flagged but did NOT write to disk
|
||||
# (local modifications, files deleted on main). No file changes to propose —
|
||||
# just alert Slack so a human can manually reconcile.
|
||||
- name: Notify Slack (review needed)
|
||||
if: always() && steps.sync.outputs.action == 'push_and_pr'
|
||||
# (local modifications, files deleted on main). A PR was opened for the
|
||||
# clean-transform portion — link it so reviewers can click through.
|
||||
# Gated on pr_opened == 'true' so it only fires when we actually have a
|
||||
# PR URL (not on any error path that leaves pr_url empty).
|
||||
- name: Notify Slack (review needed, with PR)
|
||||
id: notify-review-with-pr
|
||||
if: always() && steps.push.outputs.needs_review == 'true' && steps.push.outputs.pr_opened == 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":warning: *Docs sync*: files needing manual review\n```${{ steps.sync.outputs.review_items_json }}```\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: slack-payloads/review-with-pr.json
|
||||
|
||||
# Review items but the clean-transform portion was empty so no PR was
|
||||
# opened. Gated on pr_opened == 'false' (deliberate no-PR path) — not
|
||||
# empty pr_url, which would also match error paths. Explicitly excludes
|
||||
# the existing-PR collision path, which gets its own step below.
|
||||
- name: Notify Slack (review needed, no PR)
|
||||
id: notify-review-no-pr
|
||||
if: always() && steps.sync.outputs.action == 'push_and_pr' && steps.push.outputs.pr_opened == 'false' && steps.push.outputs.existing_pr != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/review-no-pr.json
|
||||
|
||||
# Collision path: new review items flagged but an existing open
|
||||
# needs-review PR already exists — point reviewers at it.
|
||||
- name: Notify Slack (review needed, existing PR)
|
||||
id: notify-review-existing-pr
|
||||
if: always() && steps.push.outputs.existing_pr == 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/review-existing-pr.json
|
||||
|
||||
- name: Notify Slack (failure)
|
||||
if: failure() && steps.push.outputs.pr_url == ''
|
||||
id: notify-failure
|
||||
if: failure() && steps.push.outputs.pr_opened != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":x: *Docs sync*: workflow failed\n*Failed step:* ${{ steps.sync.outcome == 'failure' && 'sync-docs script' || steps.bot-token.outcome == 'failure' && 'bot token generation (check DEVOPS_BOT_PRIVATE_KEY secret)' || steps.push.outcome == 'failure' && 'push/PR creation' || 'unknown' }} | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: slack-payloads/failure.json
|
||||
|
||||
# Unconditional fallback: if any notify-* step above failed (webhook 5xx,
|
||||
# rate limit, malformed payload), fire a plain-text alert so we never
|
||||
# silently lose a review-needed or failure notification. Uses curl
|
||||
# directly so it doesn't share failure modes with the slackapi action.
|
||||
# Payload is inlined (not read from slack-payloads/) so this fallback
|
||||
# has no dependency on the Build Slack payloads step succeeding — if
|
||||
# that step broke (jq missing, mkdir failed, etc), every notify-* step
|
||||
# would fail AND the fallback could not read its file. RUN_URL is
|
||||
# constructed from GitHub-controlled env vars only (no user input), so
|
||||
# direct string interpolation into the JSON literal is safe — the
|
||||
# values cannot contain " or \.
|
||||
- name: Notify Slack (alert machinery failed)
|
||||
if: >-
|
||||
always() && (
|
||||
steps.notify-auto-sync.outcome == 'failure' ||
|
||||
steps.notify-merge-failed.outcome == 'failure' ||
|
||||
steps.notify-review-with-pr.outcome == 'failure' ||
|
||||
steps.notify-review-no-pr.outcome == 'failure' ||
|
||||
steps.notify-review-existing-pr.outcome == 'failure' ||
|
||||
steps.notify-failure.outcome == 'failure'
|
||||
)
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${SLACK_WEBHOOK:-}" ]; then
|
||||
echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set — cannot post fallback alert"
|
||||
exit 0
|
||||
fi
|
||||
RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
curl -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"text\": \":rotating_light: *Docs sync*: review/alert machinery failed — check Actions UI ${RUN_URL}\"}" \
|
||||
"$SLACK_WEBHOOK" || echo "::warning::fallback Slack post also failed"
|
||||
|
||||
@@ -74,11 +74,33 @@ jobs:
|
||||
|
||||
- name: Build Slack payload
|
||||
if: failure()
|
||||
id: slack-payload
|
||||
env:
|
||||
DETAILS_RAW: ${{ steps.failures.outputs.details }}
|
||||
run: |
|
||||
SUMMARY=$(echo "${{ steps.failures.outputs.details }}" | head -3 | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-200)
|
||||
# Strip ANSI sequences (SGR, OSC, and G0/G1 charset designators), then truncate
|
||||
# to 200 bytes and drop any trailing partial UTF-8 bytes so we don't emit mojibake.
|
||||
SUMMARY=$(printf '%s' "$DETAILS_RAW" | head -3 \
|
||||
| sed -E 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g; s/\x1b[()][A-Za-z0-9]//g' \
|
||||
| head -c 200 | iconv -f UTF-8 -t UTF-8//IGNORE)
|
||||
if [ -z "$SUMMARY" ]; then
|
||||
SUMMARY="(no failure detail captured — see job log)"
|
||||
fi
|
||||
URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
jq -n --arg text ":x: *Showcase E2E suite failed*\n<$URL|View run>\n\`\`\`$SUMMARY\`\`\`" \
|
||||
'{text: $text}' > /tmp/slack-payload.json
|
||||
JOB_URL="${URL}/job/${{ github.job }}"
|
||||
|
||||
SLACK_MSG=$(mktemp)
|
||||
SLACK_PAYLOAD=$(mktemp)
|
||||
|
||||
# Build the message with REAL newlines, then hand it to jq via --rawfile so escaping is handled correctly.
|
||||
{
|
||||
printf ':x: *Showcase E2E suite failed*\n'
|
||||
printf '<%s|View run> · <%s|View job>\n' "$URL" "$JOB_URL"
|
||||
printf '```\n%s\n```\n' "$SUMMARY"
|
||||
} > "$SLACK_MSG"
|
||||
jq -n --rawfile text "$SLACK_MSG" '{text: $text}' > "$SLACK_PAYLOAD"
|
||||
rm -f "$SLACK_MSG"
|
||||
echo "payload_path=${SLACK_PAYLOAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post failure to Slack
|
||||
if: failure()
|
||||
@@ -86,33 +108,11 @@ jobs:
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/slack-payload.json
|
||||
payload-file-path: ${{ steps.slack-payload.outputs.payload_path }}
|
||||
|
||||
- name: Create issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = '[Drift] Showcase E2E suite failing';
|
||||
const body = `## E2E Drift Detection Alert\n\n**Run**: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n**Schedule**: ${context.payload.schedule || 'manual'}\n\nThe centralized E2E smoke suite is failing. Please investigate.`;
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'showcase-drift',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const existing = issues.find(i => i.title === title);
|
||||
if (!existing) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['showcase-drift'],
|
||||
});
|
||||
}
|
||||
- name: Clean up Slack payload tmpfile
|
||||
if: always() && steps.slack-payload.outputs.payload_path
|
||||
run: rm -f "${{ steps.slack-payload.outputs.payload_path }}"
|
||||
|
||||
version-drift:
|
||||
name: Version Drift Report
|
||||
@@ -195,59 +195,6 @@ jobs:
|
||||
echo -e "$report" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create drift report issue
|
||||
id: drift_issue
|
||||
if: steps.python_drift.outputs.report != '' || steps.npm_drift.outputs.report != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pythonDrift = `${{ steps.python_drift.outputs.report }}`.trim();
|
||||
const npmDrift = `${{ steps.npm_drift.outputs.report }}`.trim();
|
||||
|
||||
if (!pythonDrift && !npmDrift) return;
|
||||
|
||||
const title = `[Drift] Showcase packages have outdated pinned versions`;
|
||||
const body = `## Weekly Version Drift Report
|
||||
|
||||
Showcase packages have pinned versions that differ from the latest releases.
|
||||
Review each and update if the new version is compatible.
|
||||
|
||||
${pythonDrift ? `### Python Packages\n| Package | Dep | Pinned | Latest |\n|---------|-----|--------|--------|\n${pythonDrift}` : ''}
|
||||
|
||||
${npmDrift ? `### npm Packages\n| Package | Dep | Pinned | Latest |\n|---------|-----|--------|--------|\n${npmDrift}` : ''}
|
||||
|
||||
**Action**: For each outdated dep, check the Dojo example for the correct version.
|
||||
Update \`requirements.txt\` / \`package.json\`, rebuild, and verify demos still work.
|
||||
`;
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'showcase-drift,version-drift',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
let issueUrl;
|
||||
if (issues.length === 0) {
|
||||
const { data: created } = await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['showcase-drift', 'version-drift'],
|
||||
});
|
||||
issueUrl = created.html_url;
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issues[0].number,
|
||||
body,
|
||||
});
|
||||
issueUrl = issues[0].html_url;
|
||||
}
|
||||
core.setOutput('issue_url', issueUrl);
|
||||
|
||||
- name: Notify Slack (version drift)
|
||||
if: steps.python_drift.outputs.report != '' || steps.npm_drift.outputs.report != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
@@ -255,7 +202,7 @@ jobs:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":warning: *Version drift*: dependency updates available | <${{ steps.drift_issue.outputs.issue_url }}|View issue>" }
|
||||
{ "text": ":warning: *Version drift*: dependency updates available | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
- name: Notify Slack (version drift failure)
|
||||
if: failure()
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
name: "Showcase: Drift Report"
|
||||
|
||||
# Weekly report on pin-drift baseline (validate-pins FAIL count).
|
||||
# Complements showcase_validate.yml's ratchet gating by surfacing the
|
||||
# current backlog even when no PR is open. Goal: keep the drift count
|
||||
# visible so the drift-to-zero commitment stays in front of the team.
|
||||
# Drift-to-zero work is tracked in GitHub issue #4047.
|
||||
#
|
||||
# Reference: Notion: "Showcase Drift Baseline Runbook" (accessible to
|
||||
# copilotkit.ai team members only; not linked here to avoid embedding
|
||||
# private URLs in a public workflow).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Monday 10:00 UTC — intentionally offset from showcase_drift-detection.yml's
|
||||
# Monday 09:00 UTC version-drift slot so the two reports do not pile up
|
||||
# in Slack simultaneously.
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Weekly Pin-Drift Report
|
||||
# Hoist the Slack webhook into an env var so step-level `if:`
|
||||
# expressions can reference it — `secrets.*` is not a valid
|
||||
# named-value inside `if:` and causes a workflow startup failure.
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
# Depot (Startup plan, unlimited minutes) for pnpm cache across
|
||||
# scheduled runs — keeps the weekly ratchet cheap.
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# pnpm must be set up BEFORE setup-node so that `cache: 'pnpm'` can
|
||||
# detect the pnpm binary when wiring up the store cache.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# Cache the pnpm store so weekly cold-cache runs don't re-fetch
|
||||
# the full dependency set every Monday.
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Read baseline
|
||||
id: baseline
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Emit `count|hash` so we can ratchet on both; surface "SET DRIFTED"
|
||||
# when the count matches but the hash differs.
|
||||
set +e
|
||||
baseline_tuple=$(node -e "
|
||||
let v;
|
||||
try {
|
||||
v = require('./showcase/scripts/fail-baseline.json');
|
||||
} catch (e) {
|
||||
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
|
||||
process.exit(2);
|
||||
}
|
||||
const c = v.validatePinsFailCount;
|
||||
const h = v.validatePinsFailHash;
|
||||
if (typeof c !== 'number' || !Number.isInteger(c) || c < 0) {
|
||||
console.error('fail-baseline.json: schema failure: validatePinsFailCount must be a non-negative integer');
|
||||
process.exit(3);
|
||||
}
|
||||
if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h)) {
|
||||
console.error('fail-baseline.json: schema failure: validatePinsFailHash must be a 64-char lowercase hex SHA-256');
|
||||
process.exit(3);
|
||||
}
|
||||
console.log(c + '|' + h);
|
||||
")
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# Preserve node's distinct rc (2=JSON syntax, 3=schema) in the
|
||||
# annotation so the weekly log pinpoints the cause.
|
||||
echo "::error::Failed to read baseline from showcase/scripts/fail-baseline.json (node exit=$rc; 2=JSON syntax, 3=schema)"
|
||||
exit "$rc"
|
||||
fi
|
||||
baseline_count="${baseline_tuple%%|*}"
|
||||
baseline_hash="${baseline_tuple##*|}"
|
||||
echo "count=$baseline_count" >> "$GITHUB_OUTPUT"
|
||||
echo "hash=$baseline_hash" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run validate-pins
|
||||
id: validate
|
||||
working-directory: showcase/scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Temporarily disable -e around validate-pins so we can capture its
|
||||
# exit code ourselves (we expect 0 or 1 as legitimate outcomes and
|
||||
# need to distinguish 2+ as an internal crash). pipefail stays on:
|
||||
# the `{ grep || true; }` scoping below relies on pipefail being
|
||||
# enabled to actually deliver on its stated protection (scope no-
|
||||
# match tolerance to grep alone without swallowing producer/head
|
||||
# failures). -u stays on to catch accidental unset-var reads.
|
||||
set +e
|
||||
stderr_file=$(mktemp)
|
||||
stdout=$(pnpm exec tsx validate-pins.ts 2>"$stderr_file")
|
||||
rc=$?
|
||||
stderr=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
set -e
|
||||
printf '%s\n' "$stdout"
|
||||
printf '%s\n' "$stderr" >&2
|
||||
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
|
||||
# Preserve validate-pins.ts's distinct exit code (2=EXIT_INTERNAL,
|
||||
# 3=EXIT_UNREADABLE, 4+=future) so the weekly job signal
|
||||
# distinguishes "validator crashed" from "pin drift found"
|
||||
# (rc=1). Matches the same pattern in showcase_validate.yml.
|
||||
echo "::error::validate-pins.ts exited with unexpected code $rc"
|
||||
exit "$rc"
|
||||
fi
|
||||
# Scope grep's no-match tolerance to grep alone: a trailing
|
||||
# `|| true` on the whole pipeline would defeat `pipefail` (if it
|
||||
# were enabled) and swallow producer/head failures too. Wrapping
|
||||
# just grep in `{ ... || true; }` keeps other stages' errors
|
||||
# visible while still tolerating "Summary line absent" (which is
|
||||
# then reported explicitly by the `[ -z "$summary_line" ]` check).
|
||||
summary_line=$(printf '%s\n' "$stdout" | { grep -E '^[[:space:]]*Summary:' || true; } | head -n 1)
|
||||
if [ -z "$summary_line" ]; then
|
||||
echo "::error::Could not find validate-pins 'Summary:' line"
|
||||
exit 1
|
||||
fi
|
||||
actual=$(printf '%s\n' "$summary_line" | grep -oE '\bFAIL=[0-9]+\b' | head -n 1 | cut -d= -f2)
|
||||
if [ -z "${actual:-}" ] || ! [[ "$actual" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Could not parse FAIL=<int> from Summary line"
|
||||
exit 1
|
||||
fi
|
||||
# Hash the sorted, deduplicated `[FAIL] ...` lines (stderr only),
|
||||
# matching the identical computation in showcase_validate.yml. This
|
||||
# catches the "count equal but set drifted" case: one FAIL healed
|
||||
# while another regressed.
|
||||
#
|
||||
# Scope grep's no-match tolerance to grep alone by wrapping just
|
||||
# the grep stage in `{ ... || true; }`. Without this, a zero-FAIL
|
||||
# week (grep exit 1 = no match) either fails the pipeline (if
|
||||
# pipefail is enabled) or — more insidiously — silently yields
|
||||
# the empty-input SHA-256 (`e3b0c44...`), which then mismatches
|
||||
# the recorded baseline hash and spuriously fires a "SET DRIFTED"
|
||||
# alert on a clean week. A trailing `|| true` on the whole
|
||||
# pipeline would defeat `pipefail` and also swallow real
|
||||
# sort/shasum/cut failures; we only tolerate grep's no-match.
|
||||
actual_hash=$(printf '%s\n' "$stderr" | { grep -E '^\[FAIL\]' || true; } | LC_ALL=C sort -u | shasum -a 256 | cut -d' ' -f1)
|
||||
# Determine set-drift status for the weekly Slack payload so a
|
||||
# count-equal-but-set-changed week is visible, not silently clean.
|
||||
if [ "$actual" -eq "${{ steps.baseline.outputs.count }}" ] && [ "$actual_hash" != "${{ steps.baseline.outputs.hash }}" ]; then
|
||||
set_status="SET DRIFTED"
|
||||
else
|
||||
set_status="ok"
|
||||
fi
|
||||
echo "actual=$actual" >> "$GITHUB_OUTPUT"
|
||||
echo "actual_hash=$actual_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "set_status=$set_status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify Slack (weekly drift report)
|
||||
# Skip cleanly when the webhook secret is unset (forks / pre-provision)
|
||||
# rather than failing the job. Counts still appear in the job log.
|
||||
if: ${{ env.SLACK_WEBHOOK != '' }}
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
||||
# if hash/count/set_status ever contains characters that would
|
||||
# break the JSON payload (quotes, backslashes, newlines), the
|
||||
# value is safely JSON-encoded instead of injected as raw text.
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':chart_with_downwards_trend: *Showcase pin-drift (weekly)*: FAIL={0} (baseline {1}) [{2}] | <https://github.com/{3}/actions/runs/{4}|View run>', steps.validate.outputs.actual, steps.baseline.outputs.count, steps.validate.outputs.set_status, github.repository, github.run_id)) }} }
|
||||
|
||||
- name: Log result (no Slack)
|
||||
if: ${{ env.SLACK_WEBHOOK == '' }}
|
||||
run: |
|
||||
echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set; weekly drift report not sent to Slack."
|
||||
echo "Weekly pin-drift report: FAIL=${{ steps.validate.outputs.actual }} baseline=${{ steps.baseline.outputs.count }} set_status=${{ steps.validate.outputs.set_status }} actual_hash=${{ steps.validate.outputs.actual_hash }} baseline_hash=${{ steps.baseline.outputs.hash }}"
|
||||
|
||||
- name: Notify Slack (job failure)
|
||||
# Surface silent crashes (baseline read, validate-pins internal error,
|
||||
# Slack post failure) so the weekly report doesn't fail invisibly.
|
||||
if: ${{ failure() && env.SLACK_WEBHOOK != '' }}
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
||||
# if repository/run_id ever contains characters that would break
|
||||
# the JSON payload (quotes, backslashes, newlines), the value is
|
||||
# safely JSON-encoded instead of injected as raw text. Mirrors
|
||||
# the success payload above for consistency.
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':x: *Showcase drift report*: job failed | <https://github.com/{0}/actions/runs/{1}|View run>', github.repository, github.run_id)) }} }
|
||||
@@ -15,6 +15,17 @@ jobs:
|
||||
packages: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout (starters dir only)
|
||||
# Sparse checkout of showcase/starters/ so the "Check image drift"
|
||||
# step can enumerate starter slugs from the filesystem. This keeps
|
||||
# the starter list a single source of truth (directory names under
|
||||
# showcase/starters/) instead of a literal list duplicated in this
|
||||
# workflow.
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: showcase/starters
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Restore state from cache
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -183,12 +194,41 @@ jobs:
|
||||
|
||||
STALE=""
|
||||
STALE_LIST=""
|
||||
# Non-starter services stay literal — they don't live under
|
||||
# showcase/starters/ and each has bespoke provisioning elsewhere.
|
||||
SERVICES=(
|
||||
shell langgraph-python langgraph-typescript langgraph-fastapi
|
||||
mastra crewai-crews pydantic-ai google-adk ag2 agno llamaindex
|
||||
strands ms-agent-python ms-agent-dotnet claude-sdk-python
|
||||
claude-sdk-typescript langroid spring-ai aimock
|
||||
)
|
||||
# Starter slugs are derived from showcase/starters/*/ so adding a
|
||||
# new starter directory automatically extends drift detection.
|
||||
# `template/` is scaffolding, not a deployed service — keep in sync
|
||||
# with showcase/scripts/validate-workflow-starters.ts EXCLUDED_DIRS.
|
||||
# nullglob guards against an empty showcase/starters/ tree expanding
|
||||
# the literal "showcase/starters/*/" pattern into the iteration,
|
||||
# which would corrupt SERVICES with a "starter-*" entry.
|
||||
shopt -s nullglob
|
||||
for dir in showcase/starters/*/; do
|
||||
slug=$(basename "$dir")
|
||||
[ "$slug" = "template" ] && continue
|
||||
SERVICES+=("starter-$slug")
|
||||
done
|
||||
shopt -u nullglob
|
||||
# Count appended starter-* entries independently of the non-starter
|
||||
# list size. Using a magic `-eq 19` sentinel tied us to a specific
|
||||
# non-starter count; any add/remove there would have silently
|
||||
# disabled this guard. `grep -c '^starter-'` stays correct under
|
||||
# arbitrary churn to the literal non-starter list.
|
||||
STARTER_COUNT=$(printf '%s\n' "${SERVICES[@]}" | grep -c '^starter-' || true)
|
||||
if [ "$STARTER_COUNT" -eq 0 ]; then
|
||||
# Sparse checkout failed to populate showcase/starters/, or every
|
||||
# directory under it was `template/` — fail loudly instead of
|
||||
# silently under-checking.
|
||||
echo "::error::No starter directories found under showcase/starters/ — sparse checkout failed?"
|
||||
exit 1
|
||||
fi
|
||||
# Compare against the last commits that touched showcase-related paths,
|
||||
# NOT main HEAD. Deploys only trigger on showcase/ and examples/integrations/
|
||||
# changes, so non-showcase commits shouldn't make images appear stale.
|
||||
@@ -209,21 +249,76 @@ jobs:
|
||||
|
||||
for SVC in "${SERVICES[@]}"; do
|
||||
PKG="showcase-${SVC}"
|
||||
# Get tags for the latest version via GitHub Packages API
|
||||
TAGS=$(gh api "/orgs/copilotkit/packages/container/${PKG}/versions?per_page=1" \
|
||||
--jq '.[0].metadata.container.tags | join(" ")' 2>/dev/null) || true
|
||||
# Query GHCR for the latest package version. The previous
|
||||
# implementation used `gh api ... || true` + `[ -z "$TAGS" ]`
|
||||
# to skip, which silently conflated three distinct cases:
|
||||
# - new service, not yet published (legitimate, expected)
|
||||
# - GHCR 404 (legitimate — same as above)
|
||||
# - GHCR 401/403/5xx (transient API error — NOT legitimate;
|
||||
# should surface so a broken drift run doesn't look clean)
|
||||
# Split them: capture HTTP status from gh api -i, treat 200+empty
|
||||
# and 404 as "no versions yet" (quiet continue), and warn on any
|
||||
# other non-200 so the run log shows a ::warning:: without
|
||||
# failing the whole drift detector over one flaky service.
|
||||
# Capture stderr separately from stdout. Merging them with 2>&1
|
||||
# can splice gh error lines (auth failures, rate limits, network
|
||||
# errors) ahead of the HTTP header block, which then corrupts
|
||||
# HTTP_STATUS and API_BODY parsing — a drift run can look "clean"
|
||||
# while actually failing every call. Keep stderr in a temp file
|
||||
# and surface it only when we need to diagnose a non-zero RC.
|
||||
API_STDERR=$(mktemp)
|
||||
set +e
|
||||
API_RESPONSE=$(gh api -i "/orgs/copilotkit/packages/container/${PKG}/versions?per_page=1" 2>"$API_STDERR")
|
||||
API_RC=$?
|
||||
set -e
|
||||
# gh api -i emits HTTP headers on stdout followed by a blank
|
||||
# line then the body. Extract the status line (first line
|
||||
# beginning with HTTP/) even if redirects prepended extras.
|
||||
HTTP_STATUS=$(printf '%s\n' "$API_RESPONSE" | awk '/^HTTP\// { status=$2 } END { print status }')
|
||||
# Body is everything after the first blank line.
|
||||
API_BODY=$(printf '%s\n' "$API_RESPONSE" | awk 'blank { print; next } /^\r?$/ { blank=1 }')
|
||||
|
||||
if [ -z "$TAGS" ]; then
|
||||
continue # No package versions — new service, not yet built
|
||||
if [ "$API_RC" -ne 0 ] && [ -z "$HTTP_STATUS" ]; then
|
||||
# gh itself failed (network, missing binary) with no response
|
||||
# at all — surface as a warning and move on. A brief outage
|
||||
# shouldn't mark every service stale on the next clean run.
|
||||
GH_ERR=$(tr '\n' ' ' < "$API_STDERR" | sed 's/ */ /g' | sed 's/^ *//;s/ *$//')
|
||||
rm -f "$API_STDERR"
|
||||
echo "::warning::${SVC}: gh api call failed (rc=$API_RC): ${GH_ERR:-no stderr captured}, skipping drift check"
|
||||
continue
|
||||
fi
|
||||
rm -f "$API_STDERR"
|
||||
|
||||
case "$HTTP_STATUS" in
|
||||
200)
|
||||
TAGS=$(printf '%s' "$API_BODY" | jq -r '.[0].metadata.container.tags | join(" ")' 2>/dev/null) || TAGS=""
|
||||
if [ -z "$TAGS" ]; then
|
||||
echo " ${SVC}: no package versions yet, skipping"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
404)
|
||||
echo " ${SVC}: no GHCR package yet (404), skipping"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
# 401/403 (auth drift), 5xx (transient), any other surprise.
|
||||
# Warn visibly but don't fail the whole drift run — one
|
||||
# flaky service shouldn't break the monitor.
|
||||
echo "::warning::${SVC}: GHCR returned HTTP ${HTTP_STATUS:-<no status>}, skipping"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Image is up to date if it matches EITHER the last showcase/ or
|
||||
# examples/integrations/ commit (deploy triggers on both paths)
|
||||
# examples/integrations/ commit (deploy triggers on both paths).
|
||||
# Use `grep -w` (word boundary) instead of `grep` (substring)
|
||||
# so one SHA isn't accidentally matched as a prefix of another.
|
||||
UP_TO_DATE=false
|
||||
if [ -n "$SHOWCASE_SHA" ] && echo "$TAGS" | grep -q "$SHOWCASE_SHA"; then
|
||||
if [ -n "$SHOWCASE_SHA" ] && echo "$TAGS" | grep -qw "$SHOWCASE_SHA"; then
|
||||
UP_TO_DATE=true
|
||||
fi
|
||||
if [ -n "$EXAMPLES_SHA" ] && echo "$TAGS" | grep -q "$EXAMPLES_SHA"; then
|
||||
if [ -n "$EXAMPLES_SHA" ] && echo "$TAGS" | grep -qw "$EXAMPLES_SHA"; then
|
||||
UP_TO_DATE=true
|
||||
fi
|
||||
if [ "$UP_TO_DATE" = true ]; then
|
||||
@@ -266,29 +361,77 @@ jobs:
|
||||
fi # end SHA guard
|
||||
|
||||
- name: Trigger rebuild for stale services
|
||||
id: rebuild
|
||||
if: steps.image_drift.outputs.has_stale == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
FAILED=""
|
||||
FAILED_COUNT=0
|
||||
: > rebuild-failures.txt
|
||||
COUNT=0
|
||||
for SVC in ${{ steps.image_drift.outputs.stale_services }}; do
|
||||
echo "Triggering rebuild for ${SVC}..."
|
||||
if ! gh workflow run showcase_deploy.yml -f service="${SVC}"; then
|
||||
echo "::warning::Failed to trigger rebuild for ${SVC}"
|
||||
RC=0
|
||||
ERR_OUTPUT=$(gh workflow run showcase_deploy.yml --repo "${{ github.repository }}" -f service="${SVC}" 2>&1) || RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
COUNT=$((COUNT + 1))
|
||||
else
|
||||
echo "::warning::Failed to trigger rebuild for ${SVC}: ${ERR_OUTPUT}"
|
||||
# Collapse whitespace/newlines in the error so it fits on one Slack line
|
||||
REASON=$(echo "$ERR_OUTPUT" | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ *//;s/ *$//')
|
||||
if [ -z "$REASON" ]; then
|
||||
REASON="unknown error"
|
||||
fi
|
||||
FAILED="${FAILED} ${SVC}"
|
||||
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||
printf ":x: *%s* — %s\n" "$SVC" "$REASON" >> rebuild-failures.txt
|
||||
fi
|
||||
done
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "triggered_count=${COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "failed_count=${FAILED_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
# Surface failures via annotation and fail the job so the run shows
|
||||
# RED in GitHub Actions UI / `gh run list`. Slack dedup is handled
|
||||
# by guarding the failure() notifier with
|
||||
# `steps.image_drift.outputs.has_stale != 'true'`, so this exit 1
|
||||
# does NOT cause double-posting — the detailed drift-alert step
|
||||
# covers the drift path.
|
||||
echo "::error::Failed to trigger rebuilds for:${FAILED}"
|
||||
echo "has_failures=true" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
else
|
||||
echo "has_failures=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Alert image drift to Slack
|
||||
if: always() && steps.image_drift.outputs.has_stale == 'true'
|
||||
run: |
|
||||
HEADER=":package: *Image drift detected — rebuilds triggered:*"
|
||||
BODY=$(cat image-drift.txt)
|
||||
printf "%s\n%s" "$HEADER" "$BODY" > drift-message.txt
|
||||
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
COUNT="${{ steps.rebuild.outputs.triggered_count }}"
|
||||
FAILED_COUNT="${{ steps.rebuild.outputs.failed_count }}"
|
||||
if [ -z "$COUNT" ]; then
|
||||
# Fallback: rebuild step didn't run or didn't set the output; count from stale_services
|
||||
COUNT=$(echo "${{ steps.image_drift.outputs.stale_services }}" | wc -w | tr -d ' ')
|
||||
fi
|
||||
if [ -z "$FAILED_COUNT" ]; then
|
||||
FAILED_COUNT=0
|
||||
fi
|
||||
NOUN="rebuilds"
|
||||
if [ "$COUNT" = "1" ]; then NOUN="rebuild"; fi
|
||||
|
||||
if [ "${{ steps.rebuild.outputs.has_failures }}" = "true" ] && [ -s rebuild-failures.txt ]; then
|
||||
# Failure case: list only the services that failed to rebuild, with reasons.
|
||||
# COUNT reflects only successfully-triggered rebuilds; FAILED_COUNT is the rest.
|
||||
{
|
||||
printf ":package: *Image drift detected — %s %s triggered, %s failed:*\n" "$COUNT" "$NOUN" "$FAILED_COUNT"
|
||||
cat rebuild-failures.txt
|
||||
printf "<%s|Workflow run>\n" "$RUN_URL"
|
||||
} > drift-message.txt
|
||||
else
|
||||
# Success case: just summarize the count with a link to the run
|
||||
printf ":package: Image drift detected — %s %s triggered (<%s|run>)\n" "$COUNT" "$NOUN" "$RUN_URL" > drift-message.txt
|
||||
fi
|
||||
jq -n --rawfile text drift-message.txt '{"text": $text}' > drift-payload.json
|
||||
|
||||
- name: Post image drift to Slack
|
||||
@@ -300,7 +443,11 @@ jobs:
|
||||
payload-file-path: drift-payload.json
|
||||
|
||||
- name: Notify Slack (workflow failure)
|
||||
if: failure()
|
||||
# Suppress when the drift-alert path is active — that step already
|
||||
# posts a detailed Slack message covering per-service rebuild results.
|
||||
# This generic notifier still fires for non-drift failures (e.g., the
|
||||
# smoke-check step itself fails before drift detection runs).
|
||||
if: failure() && steps.image_drift.outputs.has_stale != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
|
||||
@@ -4,20 +4,61 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "showcase/**"
|
||||
- "examples/integrations/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- ".github/workflows/showcase_validate.yml"
|
||||
# Also re-run when starter/workflow files change — the
|
||||
# validate-workflow-starters step below verifies parity between
|
||||
# showcase/starters/* and the SERVICES/options lists in these files.
|
||||
- ".github/workflows/showcase_deploy.yml"
|
||||
- ".github/workflows/showcase_smoke-monitor.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "showcase/**"
|
||||
- "examples/integrations/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- ".github/workflows/showcase_validate.yml"
|
||||
- ".github/workflows/showcase_deploy.yml"
|
||||
- ".github/workflows/showcase_smoke-monitor.yml"
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
# Split concurrency per event so main-branch push runs are never canceled
|
||||
# mid-execution (we need Slack failure alerts to fire reliably). PR runs
|
||||
# still cancel in progress to keep PR CI responsive.
|
||||
concurrency:
|
||||
group: showcase-validate-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: showcase-validate-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Showcase
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# Hoist the Slack webhook into an env var so step-level `if:`
|
||||
# expressions can reference it — `secrets.*` is not a valid
|
||||
# named-value inside `if:` and causes a workflow startup failure
|
||||
# on push events.
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
# Depot (Startup plan, unlimited minutes) for persistent pnpm/npm
|
||||
# cache across runs — cold ubuntu-latest runs were ~18-20m; Depot
|
||||
# typically reduces to ~5-8m. 25m timeout retained as headroom.
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
# Pin shell so `set -euo pipefail` + `mapfile` behave the same
|
||||
# across any future runner image changes (default on ubuntu is
|
||||
# already bash, but we lock it explicitly).
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -27,38 +68,522 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# Cache npm for the showcase/shell `npm ci` step below (shell is
|
||||
# NOT a pnpm workspace member; it ships its own package-lock.json).
|
||||
cache: "npm"
|
||||
cache-dependency-path: showcase/shell/package-lock.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
# Pinned to a specific minor rather than floating @v4 so that a
|
||||
# silent upstream major/minor change can't alter install semantics
|
||||
# on a random CI run. Bump deliberately when refreshing the toolchain.
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
|
||||
- name: Verify lockfile is up to date
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Enforce e2e spec count (baseline per package)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
# Single source of truth: showcase/scripts/fail-baseline.json
|
||||
# `baselineDemoCount` is read here AND by validate-parity.ts so the
|
||||
# per-package e2e-spec-count floor cannot drift between CI and the
|
||||
# validator. If parsing fails we distinguish JSON syntax errors
|
||||
# from schema failures (missing/non-integer/negative field).
|
||||
set +e
|
||||
MIN=$(node -e "
|
||||
let v;
|
||||
try {
|
||||
v = require('./showcase/scripts/fail-baseline.json');
|
||||
} catch (e) {
|
||||
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
|
||||
process.exit(2);
|
||||
}
|
||||
const n = v.baselineDemoCount;
|
||||
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
|
||||
console.error('fail-baseline.json: schema failure: baselineDemoCount must be a non-negative integer');
|
||||
process.exit(3);
|
||||
}
|
||||
console.log(n);
|
||||
")
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# Preserve node's distinct rc (2=JSON syntax, 3=schema) in the
|
||||
# annotation so the CI log pinpoints the cause without re-running.
|
||||
echo "::error::Failed to read baselineDemoCount from showcase/scripts/fail-baseline.json (node exit=$rc; 2=JSON syntax, 3=schema)"
|
||||
exit "$rc"
|
||||
fi
|
||||
failed=0
|
||||
found=0
|
||||
for pkg_dir in showcase/packages/*/; do
|
||||
[ -d "$pkg_dir" ] || continue
|
||||
found=$((found + 1))
|
||||
pkg=$(basename "$pkg_dir")
|
||||
e2e_dir="${pkg_dir}tests/e2e/"
|
||||
if [ ! -d "$e2e_dir" ]; then
|
||||
echo "::error file=$pkg_dir::Package '$pkg' is missing tests/e2e/ directory (required for baseline e2e coverage)"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
# Capture `find` output into a variable first so we can check
|
||||
# its exit status directly. Bash process substitution (used with
|
||||
# `mapfile < <(cmd)`) does NOT propagate the producer's exit
|
||||
# status to the parent shell — `mapfile` only reports its own
|
||||
# usage errors — so a failing `find` (EACCES on a subdir, ELOOP,
|
||||
# transient I/O) would have been silently treated as "zero
|
||||
# specs" and surfaced as the misleading "minimum required"
|
||||
# error instead of the real root cause. Command substitution
|
||||
# propagates `find`'s status via `$?` on the assignment, which
|
||||
# we check immediately. A zero-spec result is a legitimate
|
||||
# success from `find` and is handled by the `$count -lt $MIN`
|
||||
# check below, not treated as a find failure.
|
||||
# Aggregate find failures with the rest of the per-package
|
||||
# failure modes (missing tests/e2e/, below-MIN count) so one bad
|
||||
# package doesn't short-circuit reporting for the others. A
|
||||
# single CI run should surface every problematic package at
|
||||
# once; `exit "$failed"` at the end of the loop reports the
|
||||
# aggregate.
|
||||
if ! find_out=$(find "$e2e_dir" -maxdepth 1 -type f -name '*.spec.ts'); then
|
||||
echo "::error file=$e2e_dir::find failed while enumerating specs for '$pkg'"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
specs=()
|
||||
# Only populate the array if `find` produced output; `mapfile
|
||||
# <<< ""` would otherwise create a single empty element and
|
||||
# inflate the count by one.
|
||||
if [ -n "$find_out" ]; then
|
||||
mapfile -t specs <<< "$find_out"
|
||||
fi
|
||||
count=${#specs[@]}
|
||||
if [ "$count" -lt "$MIN" ]; then
|
||||
echo "::error file=$e2e_dir::Package '$pkg' has $count e2e spec(s); minimum required is $MIN"
|
||||
failed=1
|
||||
else
|
||||
echo "ok: $pkg has $count spec(s)"
|
||||
fi
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "::error::No showcase/packages/*/ directories found — baseline check cannot run"
|
||||
exit 1
|
||||
fi
|
||||
exit "$failed"
|
||||
|
||||
- name: Run validate-parity (MUST checks gating)
|
||||
working-directory: showcase/scripts
|
||||
# MUST failures (missing manifest, missing src/app/demos dir) exit 1 and
|
||||
# fail the PR. SHOULD deviations print warnings and exit 0. See
|
||||
# showcase/scripts/validate-parity.ts for the full policy.
|
||||
#
|
||||
# `pnpm exec` resolves tsx from the pnpm-lock.yaml-pinned workspace
|
||||
# install; `npx tsx` could fetch a drifting version on a registry
|
||||
# cache miss.
|
||||
run: pnpm exec tsx validate-parity.ts
|
||||
|
||||
- name: Run validate-workflow-starters (parity with workflows)
|
||||
working-directory: showcase/scripts
|
||||
# Gate: for every directory under showcase/starters/ (excluding
|
||||
# template/), verify `starter-<slug>` is registered in
|
||||
# showcase_deploy.yml (workflow_dispatch options AND the
|
||||
# ALL_SERVICES matrix) and in showcase_smoke-monitor.yml's
|
||||
# SERVICES array. Catches the silent-drift failure mode where a
|
||||
# new starter ships on disk but is invisible to deploy dispatch
|
||||
# or drift detection.
|
||||
run: pnpm exec tsx validate-workflow-starters.ts
|
||||
|
||||
- name: Run validate-pins (ratchet)
|
||||
working-directory: showcase/scripts
|
||||
# Ratchet gate on pin drift. Baseline (count + SHA-256 hash of sorted
|
||||
# unique FAIL lines) lives in `showcase/scripts/fail-baseline.json`;
|
||||
# see that file for the full ratchet semantics and adjustment
|
||||
# procedure. Weekly backlog visibility is provided by
|
||||
# `.github/workflows/showcase_drift-report.yml`. Drift-to-zero work
|
||||
# is tracked in GitHub issue #4047.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# --- Load + validate baseline -----------------------------------
|
||||
# `node -e` prints either a validated value or an error marker
|
||||
# we match below. We deliberately do NOT let require() throw
|
||||
# out of the subshell; we format a clean CI error instead.
|
||||
#
|
||||
# We distinguish three failure modes with distinct exit codes so
|
||||
# the CI log pinpoints the cause without requiring a re-run:
|
||||
# exit 2 => JSON syntax error (require() threw)
|
||||
# exit 3 => schema failure (missing/wrong-typed required field)
|
||||
# exit 4 => unexpected/unknown top-level field (typo guard)
|
||||
#
|
||||
# The unexpected-field check rejects silent typos like
|
||||
# `validatepinsfailcount` or an accidentally-added `comment`
|
||||
# field (distinct from the allowed leading underscore
|
||||
# `_comment`) that would otherwise leave required fields
|
||||
# undefined and be caught only via the schema branch with a
|
||||
# more confusing message.
|
||||
set +e
|
||||
baseline_json=$(node -e "
|
||||
const ALLOWED = ['_comment', 'validatePinsFailCount', 'validatePinsFailHash', 'baselineDemoCount'];
|
||||
let v;
|
||||
try {
|
||||
v = require('./fail-baseline.json');
|
||||
} catch (e) {
|
||||
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
|
||||
process.exit(2);
|
||||
}
|
||||
const unexpected = Object.keys(v).filter(k => !ALLOWED.includes(k));
|
||||
if (unexpected.length > 0) {
|
||||
console.error('fail-baseline.json: unexpected field(s): ' + unexpected.join(', ') + '. Allowed fields: ' + ALLOWED.join(', '));
|
||||
process.exit(4);
|
||||
}
|
||||
const c = v.validatePinsFailCount;
|
||||
const h = v.validatePinsFailHash;
|
||||
if (typeof c !== 'number' || !Number.isInteger(c) || c < 0) {
|
||||
console.error('fail-baseline.json: schema failure: validatePinsFailCount must be a non-negative integer');
|
||||
process.exit(3);
|
||||
}
|
||||
if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h)) {
|
||||
console.error('fail-baseline.json: schema failure: validatePinsFailHash must be a 64-char lowercase hex SHA-256');
|
||||
process.exit(3);
|
||||
}
|
||||
console.log(JSON.stringify({ count: c, hash: h }));
|
||||
")
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# Preserve node's distinct rc (2=JSON syntax, 3=schema, 4=unexpected field)
|
||||
# in the annotation so the CI log pinpoints the cause.
|
||||
echo "::error::fail-baseline.json failed validation (node exit=$rc; 2=JSON syntax, 3=schema, 4=unexpected field)"
|
||||
exit "$rc"
|
||||
fi
|
||||
baseline=$(node -e "console.log(JSON.parse(process.argv[1]).count)" "$baseline_json")
|
||||
baseline_hash=$(node -e "console.log(JSON.parse(process.argv[1]).hash)" "$baseline_json")
|
||||
|
||||
# --- Run validator; separate internal crash from pin-drift exit -
|
||||
# validate-pins exits 0 when FAIL=0, 1 when FAIL>0. Anything else
|
||||
# (2+, uncaught throw, node crash, SIGSEGV) is an internal failure
|
||||
# we must surface distinctly from a legitimate drift report.
|
||||
#
|
||||
# We deliberately keep stdout and stderr in separate variables.
|
||||
# validate-pins.ts emits progress/summary on stdout and `[FAIL]`
|
||||
# lines on stderr; mingling them with `2>&1` allowed progress
|
||||
# chatter (or future stdout additions) to corrupt the hash input.
|
||||
# The hash is computed strictly from stderr.
|
||||
set +e
|
||||
stderr_file=$(mktemp)
|
||||
stdout=$(pnpm exec tsx validate-pins.ts 2>"$stderr_file")
|
||||
rc=$?
|
||||
stderr=$(cat "$stderr_file")
|
||||
rm -f "$stderr_file"
|
||||
set -e
|
||||
# Replay both streams to the job log so humans can debug.
|
||||
printf '%s\n' "$stdout"
|
||||
printf '%s\n' "$stderr" >&2
|
||||
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
|
||||
# Preserve validate-pins.ts's distinct exit code (2=EXIT_INTERNAL,
|
||||
# 3=EXIT_UNREADABLE, 4+=future) so downstream consumers can
|
||||
# distinguish "validator crashed" from "pin drift found" (which
|
||||
# would be rc=1). Collapsing to `exit 1` would make an internal
|
||||
# crash indistinguishable from legitimate drift in the PR check
|
||||
# signal.
|
||||
echo "::error::validate-pins.ts exited with unexpected code $rc (expected 0 or 1). This indicates an internal failure, not pin drift."
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
# --- Parse Summary line (actual FAIL count) ---------------------
|
||||
# Summary line is on stdout. If the validator output format
|
||||
# changed (missing Summary, non-numeric FAIL), fail loudly
|
||||
# instead of silently treating it as zero.
|
||||
#
|
||||
# Scope grep's no-match tolerance to grep alone by wrapping just
|
||||
# the grep stage in a `{ ... || true; }` group. A trailing
|
||||
# `|| true` on the whole pipeline would defeat `pipefail` and
|
||||
# swallow producer/head failures too; we only want to tolerate
|
||||
# grep finding no match (which `[ -z "$summary_line" ]` below
|
||||
# already reports with a precise error).
|
||||
summary_line=$(printf '%s\n' "$stdout" | { grep -E '^[[:space:]]*Summary:' || true; } | head -n 1)
|
||||
if [ -z "$summary_line" ]; then
|
||||
echo "::error::Could not find validate-pins 'Summary:' line in output"
|
||||
exit 1
|
||||
fi
|
||||
# Word-boundary anchored to avoid matching e.g. `NEWFAIL=` or
|
||||
# `TOTALFAIL=` if such tokens are ever added to the Summary line.
|
||||
actual=$(printf '%s\n' "$summary_line" | grep -oE '\bFAIL=[0-9]+\b' | head -n 1 | cut -d= -f2)
|
||||
if [ -z "${actual:-}" ] || ! [[ "$actual" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Could not parse FAIL=<int> from Summary line: $summary_line"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Compute tuple hash of current FAIL set ---------------------
|
||||
# Hash the sorted, deduplicated `[FAIL] ...` lines (stderr only).
|
||||
# This catches the "count equal but set drifted" case: one FAIL
|
||||
# healed while another regressed.
|
||||
#
|
||||
# Scope grep's no-match tolerance to grep alone by wrapping just
|
||||
# the grep stage in a `{ ... || true; }` group. A trailing
|
||||
# `|| true` on the whole pipeline would defeat `pipefail` and
|
||||
# swallow sort/shasum/cut failures too; clean runs with zero
|
||||
# `[FAIL]` lines must not be an error, so we tolerate grep's
|
||||
# no-match here and only here.
|
||||
actual_hash=$(printf '%s\n' "$stderr" | { grep -E '^\[FAIL\]' || true; } | LC_ALL=C sort -u | shasum -a 256 | cut -d' ' -f1)
|
||||
|
||||
echo "validate-pins FAIL: actual=$actual baseline=$baseline"
|
||||
echo "validate-pins HASH: actual=$actual_hash baseline=$baseline_hash"
|
||||
|
||||
if [ "$actual" -gt "$baseline" ]; then
|
||||
echo "::error::Pin drift increased: $actual FAIL(s) vs baseline $baseline. Fix the new drift or, with explicit sign-off, update showcase/scripts/fail-baseline.json (bump validatePinsFailCount to $actual and validatePinsFailHash to $actual_hash)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual" -lt "$baseline" ]; then
|
||||
echo "::error::Pin drift decreased: $actual FAIL(s) vs baseline $baseline. Ratchet down the baseline in showcase/scripts/fail-baseline.json (set validatePinsFailCount=$actual, validatePinsFailHash=$actual_hash)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual_hash" != "$baseline_hash" ]; then
|
||||
echo "::error::Pin drift SET changed (count equal at $actual, hash differs). One FAIL healed while another regressed — net zero on the counter but the failing tuples are not the same set. Update showcase/scripts/fail-baseline.json (validatePinsFailHash=$actual_hash) if this is intentional, or fix the new drift."
|
||||
echo "--- FAIL lines (current) ---"
|
||||
printf '%s\n' "$stderr" | grep -E '^\[FAIL\]' | LC_ALL=C sort -u
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin drift unchanged at baseline ($baseline, hash $baseline_hash)."
|
||||
|
||||
- name: Run build pipeline tests
|
||||
working-directory: showcase/scripts
|
||||
run: npx vitest run
|
||||
# Use pnpm to resolve the workspace-installed vitest (pinned via
|
||||
# pnpm-lock.yaml) rather than `npx`, which could fetch a different
|
||||
# version on a registry cache miss.
|
||||
run: pnpm exec vitest run
|
||||
|
||||
- name: Validate manifests & generate registry
|
||||
working-directory: showcase/scripts
|
||||
run: npx tsx generate-registry.ts
|
||||
run: pnpm exec tsx generate-registry.ts
|
||||
|
||||
- name: Bundle demo content
|
||||
working-directory: showcase/scripts
|
||||
run: npx tsx bundle-demo-content.ts
|
||||
run: pnpm exec tsx bundle-demo-content.ts
|
||||
|
||||
- name: Install showcase shell dependencies
|
||||
working-directory: showcase/shell
|
||||
run: npm install --ignore-scripts
|
||||
# `showcase/shell` is NOT a pnpm workspace member (see pnpm-workspace.yaml)
|
||||
# and ships its own `package-lock.json`. Use `npm ci` to get a
|
||||
# reproducible install; `npm install` would re-resolve ranges.
|
||||
# npm cache is configured at the setup-node step above via
|
||||
# `cache-dependency-path: showcase/shell/package-lock.json`.
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build showcase shell
|
||||
working-directory: showcase/shell
|
||||
run: npm run build
|
||||
|
||||
# NOTE: Slack failure alert only fires on `push` (i.e. main-branch
|
||||
# merges) by design. PR failures already surface in the PR checks UI
|
||||
# and the PR author's inbox, and we don't want PR-author noise
|
||||
# pinging the OSS alerts channel. Tradeoff: a broken PR that sneaks
|
||||
# past review won't alert Slack until after merge.
|
||||
#
|
||||
# Extract the failed step name and first meaningful error line so the
|
||||
# Slack payload is actionable at a glance rather than forcing a
|
||||
# click-through to the workflow run. Bare "X failed" alerts bury the
|
||||
# signal; red alerts must carry triage-ready detail per the oss-alerts
|
||||
# policy. Writes `failed_step` and `error_excerpt` to $GITHUB_ENV for
|
||||
# consumption by the notify step below.
|
||||
#
|
||||
# This step must NEVER fail the job (it runs on failure() already; a
|
||||
# crash here would compound the original failure with extraction
|
||||
# noise and could block the notify step). All extraction uses `|| true`
|
||||
# fallbacks so a malformed jobs response or truncated log still yields
|
||||
# sane defaults ("unknown" / "see workflow run for details").
|
||||
- name: Extract failure details for Slack
|
||||
id: extract
|
||||
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set +e # best-effort: never block the notify step below
|
||||
|
||||
# --- Find the currently-running job and its first failed step ---
|
||||
# The jobs API returns every job in the run. We identify *this*
|
||||
# job by name (matches `jobs.validate.name`) rather than
|
||||
# job.status=='in_progress', because at this point the step we're
|
||||
# running hasn't flipped the job state yet in the API. Fall back
|
||||
# to the first job with a failed step if the name match misses
|
||||
# (e.g. future rename drift).
|
||||
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
|
||||
job_id=$(printf '%s' "$jobs_json" | jq -r '
|
||||
.jobs // []
|
||||
| map(select(.name == "Validate Showcase"))
|
||||
| (.[0].id // empty)
|
||||
' 2>/dev/null)
|
||||
if [ -z "$job_id" ]; then
|
||||
job_id=$(printf '%s' "$jobs_json" | jq -r '
|
||||
.jobs // []
|
||||
| map(select(.steps // [] | map(.conclusion) | index("failure")))
|
||||
| (.[0].id // empty)
|
||||
' 2>/dev/null)
|
||||
fi
|
||||
failed_step=$(printf '%s' "$jobs_json" | jq -r --arg id "$job_id" '
|
||||
.jobs // []
|
||||
| map(select((.id|tostring) == $id))
|
||||
| (.[0].steps // [])
|
||||
| map(select(.conclusion == "failure"))
|
||||
| (.[0].name // "unknown step")
|
||||
' 2>/dev/null)
|
||||
[ -z "$failed_step" ] && failed_step="unknown step"
|
||||
|
||||
# --- Pull log and extract first meaningful error line ------------
|
||||
# `gh run view --log-failed` output is TSV: job\tstep\ttimestamp + content.
|
||||
# Strip the three leading columns to get the raw step output, strip
|
||||
# ANSI escape codes, strip any stray BOM, skip runner/group/env
|
||||
# header noise, then grab the first line matching a recognised
|
||||
# error marker. Truncate to ~300 chars so the Slack payload stays
|
||||
# well under the 800-char budget even with escaping overhead.
|
||||
error_excerpt="see workflow run for details"
|
||||
if [ -n "$job_id" ]; then
|
||||
log_excerpt=$(gh run view "$RUN_ID" --repo "$GH_REPO" --log-failed --job="$job_id" 2>/dev/null \
|
||||
| awk -F'\t' 'NF>=3 { sub(/^[\xEF\xBB\xBF]?[0-9T:.\-Z ]+/, "", $3); print $3 }' \
|
||||
| sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
|
||||
| grep -vE '^(##\[|shell: |env: |Run |[[:space:]]*$)' \
|
||||
| grep -m1 -E '^\[(FAIL|ERROR)\]|^Error:|^error:|^::error' \
|
||||
| head -c 300)
|
||||
if [ -n "$log_excerpt" ]; then
|
||||
error_excerpt="$log_excerpt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Emit to $GITHUB_ENV using heredoc delimiter -----------------
|
||||
# Heredoc delimiter protects against values that contain `=` or
|
||||
# newlines breaking the KEY=VALUE format. The delimiter is a
|
||||
# long random-ish string unlikely to appear in any log line.
|
||||
{
|
||||
echo "failed_step<<EOF_FAILED_STEP_b3f2"
|
||||
printf '%s\n' "$failed_step"
|
||||
echo "EOF_FAILED_STEP_b3f2"
|
||||
echo "error_excerpt<<EOF_ERROR_EXCERPT_b3f2"
|
||||
printf '%s\n' "$error_excerpt"
|
||||
echo "EOF_ERROR_EXCERPT_b3f2"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
exit 0 # belt-and-suspenders: never propagate a failure
|
||||
|
||||
- name: Notify Slack (failure)
|
||||
if: failure() && github.event_name == 'push'
|
||||
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
||||
# if github.repository or the extracted failed_step / error_excerpt
|
||||
# contain characters that would break the JSON payload (quotes,
|
||||
# backslashes, newlines), the value is safely JSON-encoded instead
|
||||
# of injected as raw text. Matches the pattern used in
|
||||
# showcase_drift-report.yml. github.run_id is numeric so safe on
|
||||
# its own, but we wrap it for consistency and defense-in-depth.
|
||||
# env.failed_step and env.error_excerpt are populated by the
|
||||
# preceding "Extract failure details" step (with safe fallbacks if
|
||||
# extraction fails).
|
||||
payload: |
|
||||
{ "text": ":x: *Showcase validate*: failed | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
{ "text": ${{ toJSON(format(':x: *Showcase validate*: failed — {0}: {1} | <https://github.com/{2}/actions/runs/{3}|View run>', env.failed_step, env.error_excerpt, github.repository, github.run_id)) }} }
|
||||
|
||||
- name: Log (no Slack — webhook unset)
|
||||
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK == ''
|
||||
run: |
|
||||
echo "::warning::showcase_validate failed on push but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
|
||||
|
||||
python-unit-tests:
|
||||
name: Python unit tests (${{ matrix.python-version }})
|
||||
# Separate job so pre-existing `validate-parity` failures don't mask new
|
||||
# Python unit-test regressions. pytest runs independently of JS/TS checks.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
# Fail-fast disabled so a 3.10-only regression (e.g. typing_extensions
|
||||
# fallback path breaking) doesn't cancel the 3.12 run and leave us
|
||||
# guessing which version is the actual problem.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 3.10 covers the typing_extensions `NotRequired` fallback path used
|
||||
# by aimock_toggle.py (stdlib `NotRequired` only landed in 3.11).
|
||||
# 3.12 is the production/runner default. Pinning both guarantees we
|
||||
# catch a regression in either branch the first time it lands.
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
showcase/packages/*/requirements.txt
|
||||
|
||||
- name: Install minimal test deps
|
||||
# Always need pytest + typing_extensions. pytest-asyncio is required
|
||||
# by langroid's test_agui_adapter.py (16 tests use
|
||||
# `@pytest.mark.asyncio`); without it, pytest reports
|
||||
# "async def functions are not natively supported" and skips them.
|
||||
# pytest-mock is installed pre-emptively as it's commonly used by
|
||||
# showcase package tests and is cheap to install.
|
||||
# Per-package `requirements.txt` is installed inside the run loop
|
||||
# below so tests that import runtime deps (openai, google.genai,
|
||||
# httpx, opentelemetry, etc.) don't fail at collection time with
|
||||
# ModuleNotFoundError. Conftest-based stub finders can't help
|
||||
# because test_*.py imports the target deps BEFORE conftest runs.
|
||||
run: python -m pip install --quiet pytest pytest-asyncio pytest-mock typing_extensions
|
||||
|
||||
- name: Run showcase package Python unit tests
|
||||
# Keep scope narrow: only showcase/packages/*/tests/python/ directories
|
||||
# (not e2e, not langgraph which has its own runtime). Each package has
|
||||
# its own conftest.py that wires up import paths; we cd into the pkg
|
||||
# dir so those apply.
|
||||
#
|
||||
# Before running pytest in a package we install that package's own
|
||||
# `requirements.txt` (if present) so runtime-dep imports in test modules
|
||||
# resolve. Keeps CI parity with real runtime and avoids the fragile
|
||||
# stub-finder dance conftest.py would need to do otherwise.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
found=0
|
||||
# Current interpreter major.minor (e.g. "3.10", "3.12"). Used
|
||||
# below to skip packages whose runtime deps are incompatible
|
||||
# with the matrix Python on this job.
|
||||
py_mm=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
for pkg_dir in showcase/packages/*/; do
|
||||
tests_dir="${pkg_dir}tests/python"
|
||||
[ -d "$tests_dir" ] || continue
|
||||
found=$((found + 1))
|
||||
pkg=$(basename "$pkg_dir")
|
||||
# --- Per-package Python-version gates -------------------------
|
||||
# Skip packages whose `requirements.txt` pins a dep whose
|
||||
# `requires-python` excludes this interpreter. Surgical skip
|
||||
# (not matrix exclusion) so the rest of the packages continue
|
||||
# to exercise the 3.10 typing_extensions fallback path.
|
||||
#
|
||||
# strands: ag_ui_strands==0.1.0 declares `requires-python >=3.12,<3.14`,
|
||||
# so `pip install` fails on 3.10 before pytest even runs.
|
||||
# langroid: tests import `typing.Self` (3.11+); on 3.10 the import fails
|
||||
# at collection time. typing_extensions.Self would fix it but the tests
|
||||
# are tightly coupled to the modern typing module.
|
||||
# Revisit when ag_ui_strands relaxes its floor or when 3.10 is dropped.
|
||||
if [ "$py_mm" = "3.10" ] && { [ "$pkg" = "strands" ] || [ "$pkg" = "langroid" ]; }; then
|
||||
echo "--- pytest: $pkg --- SKIPPED on Python $py_mm (requires >=3.11/3.12)"
|
||||
continue
|
||||
fi
|
||||
echo "--- pytest: $pkg ---"
|
||||
if [ -f "${pkg_dir}requirements.txt" ]; then
|
||||
echo "Installing ${pkg_dir}requirements.txt"
|
||||
python -m pip install --quiet -r "${pkg_dir}requirements.txt" || {
|
||||
echo "::error::pip install failed for $pkg"
|
||||
failed=1
|
||||
continue
|
||||
}
|
||||
fi
|
||||
(cd "$pkg_dir" && python -m pytest tests/python/ -v) || failed=1
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "::warning::No showcase/packages/*/tests/python/ directories found"
|
||||
fi
|
||||
exit "$failed"
|
||||
|
||||
@@ -16,7 +16,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
starter-smoke:
|
||||
@@ -115,11 +114,34 @@ jobs:
|
||||
|
||||
- name: Build Slack payload
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
id: slack-payload
|
||||
env:
|
||||
SUMMARY_RAW: ${{ steps.failure-cause.outputs.summary }}
|
||||
STARTER: ${{ matrix.starter }}
|
||||
run: |
|
||||
SUMMARY=$(echo "${{ steps.failure-cause.outputs.summary }}" | head -3 | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-200)
|
||||
# Strip ANSI sequences (SGR, OSC, and G0/G1 charset designators), then truncate
|
||||
# to 200 bytes and drop any trailing partial UTF-8 bytes so we don't emit mojibake.
|
||||
SUMMARY=$(printf '%s' "$SUMMARY_RAW" | head -3 \
|
||||
| sed -E 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g; s/\x1b[()][A-Za-z0-9]//g' \
|
||||
| head -c 200 | iconv -f UTF-8 -t UTF-8//IGNORE)
|
||||
if [ -z "$SUMMARY" ]; then
|
||||
SUMMARY="(no failure detail captured — see job log)"
|
||||
fi
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
jq -n --arg text ":x: *Starter smoke test failing: ${{ matrix.starter }}*\n<$URL|View run>\n\`\`\`$SUMMARY\`\`\`" \
|
||||
'{text: $text}' > /tmp/slack-payload.json
|
||||
JOB_URL="${URL}/job/${{ github.job }}"
|
||||
|
||||
SLACK_MSG=$(mktemp)
|
||||
SLACK_PAYLOAD=$(mktemp)
|
||||
|
||||
# Build the message with REAL newlines, then hand it to jq via --rawfile so escaping is handled correctly.
|
||||
{
|
||||
printf ':x: *Starter smoke test failing: %s*\n' "$STARTER"
|
||||
printf '<%s|View run> · <%s|View job>\n' "$URL" "$JOB_URL"
|
||||
printf '```\n%s\n```\n' "$SUMMARY"
|
||||
} > "$SLACK_MSG"
|
||||
jq -n --rawfile text "$SLACK_MSG" '{text: $text}' > "$SLACK_PAYLOAD"
|
||||
rm -f "$SLACK_MSG"
|
||||
echo "payload_path=${SLACK_PAYLOAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Alert Slack on failure
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
@@ -127,30 +149,8 @@ jobs:
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/slack-payload.json
|
||||
payload-file-path: ${{ steps.slack-payload.outputs.payload_path }}
|
||||
|
||||
- name: Create GitHub issue on failure
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
FAILURE_SUMMARY: ${{ steps.failure-cause.outputs.summary }}
|
||||
with:
|
||||
script: |
|
||||
const title = `[Drift] Starter smoke test failing: ${{ matrix.starter }}`;
|
||||
const cause = (process.env.FAILURE_SUMMARY || 'Unknown').replace(/`/g, "'");
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'starter-drift',
|
||||
state: 'open',
|
||||
});
|
||||
const existing = issues.find(i => i.title === title);
|
||||
if (!existing) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
labels: ['starter-drift'],
|
||||
body: `The scheduled starter smoke test for \`${{ matrix.starter }}\` is failing.\n\n**Cause:**\n\`\`\`\n${cause}\n\`\`\`\n\n**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\nThis likely means a floating dependency update broke the starter.`,
|
||||
});
|
||||
}
|
||||
- name: Clean up Slack payload tmpfile
|
||||
if: always() && steps.slack-payload.outputs.payload_path
|
||||
run: rm -f "${{ steps.slack-payload.outputs.payload_path }}"
|
||||
|
||||
@@ -17,11 +17,16 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
starter-deployed-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Hoist the Slack webhook into an env var so step-level `if:`
|
||||
# expressions can reference it — `secrets.*` is not a valid named
|
||||
# value inside `if:` and causes a workflow startup failure on some
|
||||
# event types. Matches the pattern used in showcase_validate.yml.
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
@@ -36,6 +41,30 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
# Restore previous run's pass/fail status from cache so we can emit
|
||||
# a red→green transition alert on recovery. Mirrors the per-service
|
||||
# transition pattern in showcase_smoke-monitor.yml. The restore-keys
|
||||
# prefix gives us the most recently saved state regardless of which
|
||||
# run_id wrote it. Cache TTL is ~7 days; scheduled runs every 6h
|
||||
# plus workflow_run triggers refresh it well within that window.
|
||||
- name: Restore smoke state from cache
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: starter-smoke-state.json
|
||||
key: starter-smoke-state-impossible-match
|
||||
restore-keys: |
|
||||
starter-smoke-state-
|
||||
|
||||
- name: Initialize state if missing
|
||||
run: |
|
||||
# First-ever run (or cache eviction): assume "ok" so we don't
|
||||
# emit a false recovery alert on the first green run after
|
||||
# deploying this workflow change.
|
||||
if [ ! -f starter-smoke-state.json ]; then
|
||||
echo '{"lastStatus":"ok","lastFailureAt":""}' > starter-smoke-state.json
|
||||
fi
|
||||
|
||||
- name: Install test dependencies
|
||||
run: npm ci
|
||||
working-directory: showcase/tests
|
||||
@@ -45,46 +74,211 @@ jobs:
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run starter deployed smoke tests
|
||||
id: playwright
|
||||
working-directory: showcase/tests
|
||||
run: npx playwright test e2e/integration-smoke.spec.ts --grep "@starter-health|@starter-agent|@starter-chat"
|
||||
# Use github reporter for Actions annotations plus json for programmatic parsing.
|
||||
run: npx playwright test e2e/integration-smoke.spec.ts --grep "@starter-health|@starter-agent|@starter-chat" --reporter=github,json
|
||||
env:
|
||||
STARTER_SLUG: ${{ inputs.starter_slug || '' }}
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/results.json
|
||||
|
||||
- name: Build Slack payload
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
# Extract the failed starters + first error line so the Slack
|
||||
# payload is actionable at a glance rather than forcing a
|
||||
# click-through to the workflow run. Bare "X failed" alerts bury
|
||||
# the signal; red alerts must carry triage-ready detail per the
|
||||
# oss-alerts policy.
|
||||
#
|
||||
# Writes `failed_count`, `starters`, `error_excerpt`, and
|
||||
# `extraction_error` to $GITHUB_ENV so the Slack notify step below
|
||||
# can reference them via `env.*` (which is valid inside
|
||||
# `toJSON(format(...))` expressions). We deliberately do NOT fail
|
||||
# this step on any error: all extraction branches fall back to
|
||||
# sentinel values so the notify step still fires with best-effort
|
||||
# content.
|
||||
- name: Extract failure details
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK != ''
|
||||
id: failures
|
||||
working-directory: showcase/tests
|
||||
run: |
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
jq -n --arg text ":rotating_light: *Starter Deployed Smoke Test Failed*\n<$URL|View run>" \
|
||||
'{text: $text}' > /tmp/slack-payload.json
|
||||
set +e # best-effort: never block the notify step below
|
||||
REPORT=test-results/results.json
|
||||
EXTRACTION_ERROR=""
|
||||
FAILED_COUNT=0
|
||||
STARTERS=""
|
||||
ERROR_EXCERPT="see workflow run for details"
|
||||
|
||||
if [ ! -f "$REPORT" ]; then
|
||||
# Report file never produced (pre-test stage: install,
|
||||
# playwright install, test discovery, etc.).
|
||||
EXTRACTION_ERROR="missing_report"
|
||||
ERROR_EXCERPT="no JSON report produced — pre-test stage failure (install/setup/discovery)"
|
||||
else
|
||||
# Walk the playwright JSON suite tree, pull failed tests with:
|
||||
# title, tags, first error line. Iterate all tests per spec
|
||||
# so multi-project configs don't drop failures.
|
||||
if ! jq -r '
|
||||
[ .. | objects | select(.tests? and .title?) ] as $specs
|
||||
| $specs
|
||||
| map(
|
||||
. as $spec
|
||||
| ($spec.tests // [])[] as $t
|
||||
| ($t.results // [])[-1] as $r
|
||||
| select($r.status == "failed" or $r.status == "timedOut")
|
||||
| {
|
||||
title: $spec.title,
|
||||
tags: ($spec.title | [scan("@[a-zA-Z0-9_-]+")]),
|
||||
slug: ((try ($spec.title | capture("\\[Starter\\] (?<s>[a-zA-Z0-9_-]+)").s) catch null) // ($spec.title | .[0:40]) // "unknown"),
|
||||
error: (
|
||||
($r.error.message // $r.errors[0].message // "no error message")
|
||||
| gsub("\u001b\\[[0-9;?]*[A-Za-z]"; "")
|
||||
| split("\n")[0]
|
||||
| .[0:240]
|
||||
)
|
||||
}
|
||||
)
|
||||
' "$REPORT" > /tmp/failures.json 2> /tmp/jq-err.log; then
|
||||
echo "::warning::jq failed to parse $REPORT"
|
||||
echo "jq stderr:"; cat /tmp/jq-err.log
|
||||
echo "[]" > /tmp/failures.json
|
||||
EXTRACTION_ERROR="jq_parse_failed"
|
||||
ERROR_EXCERPT="jq failed to parse Playwright report — check workflow log"
|
||||
else
|
||||
FAILED_COUNT=$(jq 'length' /tmp/failures.json 2>/dev/null || echo 0)
|
||||
STARTERS=$(jq -r '[.[].slug] | unique | map(select(. != "")) | join(", ")' /tmp/failures.json 2>/dev/null || echo "")
|
||||
if [ "$FAILED_COUNT" = "0" ]; then
|
||||
# Report exists but jq matched zero failures — job-level
|
||||
# error (e.g. non-zero exit without failed tests).
|
||||
EXTRACTION_ERROR="no_failures_in_report"
|
||||
ERROR_EXCERPT="no test failures in report — job-level error, check run for details"
|
||||
else
|
||||
# Build first-failure excerpt: slug + first error line,
|
||||
# capped at ~240 chars. Slack payload stays well under
|
||||
# the budget even with JSON escaping overhead.
|
||||
ERROR_EXCERPT=$(jq -r '
|
||||
.[0]
|
||||
| "\(.slug): \(.error)"
|
||||
' /tmp/failures.json 2>/dev/null | head -c 240)
|
||||
[ -z "$ERROR_EXCERPT" ] && ERROR_EXCERPT="see workflow run for details"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Emit to $GITHUB_ENV using heredoc delimiters so values
|
||||
# containing `=`, quotes, or newlines don't break KEY=VALUE.
|
||||
{
|
||||
echo "failed_count=${FAILED_COUNT}"
|
||||
echo "extraction_error=${EXTRACTION_ERROR}"
|
||||
echo "starters<<EOF_STARTERS_b3f2"
|
||||
printf '%s\n' "$STARTERS"
|
||||
echo "EOF_STARTERS_b3f2"
|
||||
echo "error_excerpt<<EOF_ERR_EXCERPT_b3f2"
|
||||
printf '%s\n' "$ERROR_EXCERPT"
|
||||
echo "EOF_ERR_EXCERPT_b3f2"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
exit 0 # belt-and-suspenders: never propagate a failure
|
||||
|
||||
# Use inline `payload:` with `toJSON(format(...))` so dynamic
|
||||
# values (starters list, error excerpt) are safely JSON-encoded —
|
||||
# quotes/backslashes/newlines can't break the payload. This
|
||||
# matches the pattern used in showcase_validate.yml (PR #4068) and
|
||||
# replaces the previous `payload-file-path` approach which failed
|
||||
# with `SlackError: Invalid input! Failed to parse file extension`
|
||||
# because slackapi/slack-github-action@v2.1.0 requires `.json` /
|
||||
# `.yaml` / `.yml` and `mktemp` produces extensionless files.
|
||||
- name: Alert Slack on failure
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/slack-payload.json
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':rotating_light: *Starter Deployed Smoke Test Failed* — {0} failure(s) in [{1}]: {2} | <https://github.com/{3}/actions/runs/{4}|View run>', env.failed_count, env.starters, env.error_excerpt, github.repository, github.run_id)) }} }
|
||||
|
||||
- name: Create GitHub issue on failure
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
uses: actions/github-script@v7
|
||||
- name: Log (no Slack — webhook unset)
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK == ''
|
||||
run: |
|
||||
echo "::warning::starter_deployed_smoke failed but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
|
||||
|
||||
# Compute outcome + state transition for the next run. Runs on
|
||||
# success and failure alike (but not when the job is cancelled —
|
||||
# that's indeterminate). Policy: post recovery message on
|
||||
# red→green transition only. green→green is silent.
|
||||
#
|
||||
# This step also decides whether to save state. We save iff the
|
||||
# run had a terminal outcome (passed or failed tests); cancelled
|
||||
# runs leave the prior state untouched so streaks aren't broken.
|
||||
- name: Compute transition + recovery payload
|
||||
id: transition
|
||||
if: always() && !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
run: |
|
||||
set +e
|
||||
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
if [ ! -f starter-smoke-state.json ]; then
|
||||
# Shouldn't happen — "Initialize state if missing" runs before
|
||||
# the tests. Belt-and-suspenders fallback so this step never
|
||||
# crashes and leaves state unwritten.
|
||||
echo '{"lastStatus":"ok","lastFailureAt":""}' > starter-smoke-state.json
|
||||
fi
|
||||
PREV_STATUS=$(jq -r '.lastStatus // "ok"' starter-smoke-state.json)
|
||||
PREV_FAILURE_AT=$(jq -r '.lastFailureAt // ""' starter-smoke-state.json)
|
||||
|
||||
# The Playwright step is the only thing that can fail this job.
|
||||
# We infer success vs failure from job.status, which is exposed
|
||||
# by Actions via the `job` context — but step-level `if:` can't
|
||||
# read it directly. Instead, we use the playwright step's
|
||||
# outcome which is reliably set by the preceding step.
|
||||
PLAYWRIGHT_OUTCOME='${{ steps.playwright.outcome }}'
|
||||
case "$PLAYWRIGHT_OUTCOME" in
|
||||
success)
|
||||
NEW_STATUS="ok"
|
||||
NEW_FAILURE_AT=""
|
||||
if [ "$PREV_STATUS" = "failure" ]; then
|
||||
# Red→green transition — emit recovery. Wording mirrors
|
||||
# the smoke-monitor per-service format.
|
||||
RECOVERY_MSG=":white_check_mark: *Starter Deployed Smoke Tests*: recovered (was down since ${PREV_FAILURE_AT})"
|
||||
jq -n --arg text "$RECOVERY_MSG | <$URL|View run>" '{text: $text}' > /tmp/starter-smoke-recovery.json
|
||||
echo "should_post_recovery=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_post_recovery=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
;;
|
||||
failure|*)
|
||||
# Any non-success outcome is treated as failure for state
|
||||
# tracking. The existing "Alert Slack on failure" step
|
||||
# handles the red alert — we only touch state here.
|
||||
NEW_STATUS="failure"
|
||||
NEW_FAILURE_AT="$NOW"
|
||||
echo "should_post_recovery=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
jq -n \
|
||||
--arg status "$NEW_STATUS" \
|
||||
--arg failureAt "$NEW_FAILURE_AT" \
|
||||
'{lastStatus: $status, lastFailureAt: $failureAt}' \
|
||||
> starter-smoke-state.json
|
||||
|
||||
echo "update_state=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
- name: Alert Slack on recovery
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
steps.transition.outputs.should_post_recovery == 'true' &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_run') &&
|
||||
env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
script: |
|
||||
const title = `Starter deployed smoke test failure - ${new Date().toISOString().split('T')[0]}`;
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'starter-health',
|
||||
state: 'open',
|
||||
});
|
||||
const existing = issues.find(i => i.title === title);
|
||||
if (!existing) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
labels: ['starter-health'],
|
||||
body: `Automated starter deployed smoke test failed.\n\n[View run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`,
|
||||
});
|
||||
}
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/starter-smoke-recovery.json
|
||||
|
||||
- name: Save smoke state to cache
|
||||
if: always() && steps.transition.outputs.update_state == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: starter-smoke-state.json
|
||||
key: starter-smoke-state-${{ github.run_id }}
|
||||
|
||||
@@ -6,9 +6,15 @@ on:
|
||||
branches: [main]
|
||||
paths: ["docs/**"]
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate-model-names:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -21,7 +27,7 @@ jobs:
|
||||
- run: pnpm tsx scripts/validate-doc-model-names.ts
|
||||
|
||||
doc-tests:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
needs: validate-model-names
|
||||
steps:
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
echo "aimock binary: $AIMOCK_BIN"
|
||||
ls -la "$AIMOCK_BIN" || echo "binary not found at expected path"
|
||||
which aimock || echo "aimock not on PATH"
|
||||
nohup node $(npm root -g)/@copilotkit/aimock/dist/cli.js --fixtures scripts/doc-tests/fixtures > /tmp/aimock.log 2>&1 &
|
||||
nohup node $(npm root -g)/@copilotkit/aimock/dist/cli.js --fixtures scripts/doc-tests/fixtures --validate-on-load > /tmp/aimock.log 2>&1 &
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:4010/health; then
|
||||
echo "aimock ready"
|
||||
|
||||
@@ -19,6 +19,12 @@ on:
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -26,7 +32,7 @@ concurrency:
|
||||
jobs:
|
||||
node:
|
||||
name: "runtime / node"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -63,7 +69,7 @@ jobs:
|
||||
|
||||
bun:
|
||||
name: "runtime / bun"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -12,13 +12,19 @@ on:
|
||||
- "sdk-python/**"
|
||||
- ".github/workflows/test_unit-python-sdk.yml"
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -26,6 +26,12 @@ env:
|
||||
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
NX_CI_EXECUTION_ENV: "Unit Tests"
|
||||
|
||||
# Least-privilege by default. Individual jobs/steps can widen when needed.
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -33,7 +39,7 @@ concurrency:
|
||||
jobs:
|
||||
unit:
|
||||
name: "unit"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Update PR branch
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-branch:
|
||||
if: github.event.label.name == 'qa:update-branch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update PR branch with base
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
|
||||
try {
|
||||
await github.rest.pulls.updateBranch({ owner, repo, pull_number });
|
||||
core.info(`Updated branch for PR #${pull_number}`);
|
||||
} catch (error) {
|
||||
if (error.status === 422) {
|
||||
core.info(`Branch already up to date or cannot be updated: ${error.message}`);
|
||||
} else {
|
||||
core.setFailed(`Failed to update branch: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
name: 'qa:update-branch',
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Could not remove label: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ __pycache__/
|
||||
|
||||
docs/next-env.d.ts
|
||||
|
||||
# Showcase: staged shared contexts for Docker builds (see showcase/scripts/dev-local.sh)
|
||||
showcase/packages/*/shared_python/
|
||||
showcase/packages/*/shared_typescript/
|
||||
|
||||
# External repos (cloned for development)
|
||||
ext-apps/
|
||||
dist
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import cn from "classnames";
|
||||
import React, { useState, ReactNode, useEffect } from "react";
|
||||
import React, {
|
||||
ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Local className-joining helper so this component has no external dep.
|
||||
// Mirrors the subset of `classnames` behavior used below (strings + falsy values).
|
||||
function cn(...values: Array<string | false | null | undefined>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type TailoredContentOptionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -12,14 +24,13 @@ type TailoredContentOptionProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function TailoredContentOption({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: TailoredContentOptionProps) {
|
||||
// This is just a type definition component - it won't render anything
|
||||
return <div>{children}</div>;
|
||||
/**
|
||||
* Declarative child marker for `TailoredContent`. This component intentionally
|
||||
* renders nothing; the parent reads its props (including `children`) directly
|
||||
* and renders the selected option's content itself.
|
||||
*/
|
||||
export function TailoredContentOption(_props: TailoredContentOptionProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type TailoredContentProps = {
|
||||
@@ -30,45 +41,127 @@ type TailoredContentProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function TailoredContent({
|
||||
type IconElement = React.ReactElement<{ className?: string }>;
|
||||
|
||||
function TailoredContentInner({
|
||||
children,
|
||||
className,
|
||||
defaultOptionIndex = 0,
|
||||
id,
|
||||
header,
|
||||
}: TailoredContentProps) {
|
||||
// All hooks must run unconditionally to satisfy the Rules of Hooks.
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const warnedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Get options from children
|
||||
const options = React.Children.toArray(children).filter((child) =>
|
||||
React.isValidElement(child),
|
||||
) as React.ReactElement<TailoredContentOptionProps>[];
|
||||
// Memoize derived arrays so downstream hook deps have stable identities.
|
||||
const options = useMemo(
|
||||
() =>
|
||||
React.Children.toArray(children).filter((child) =>
|
||||
React.isValidElement(child),
|
||||
) as React.ReactElement<TailoredContentOptionProps>[],
|
||||
[children],
|
||||
);
|
||||
const optionIds = useMemo(
|
||||
() => options.map((option) => option.props.id),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error(
|
||||
"TailoredContent must have at least one TailoredContentOption child",
|
||||
// Warn (dev-mode friendly) when duplicate option ids would cause ambiguous
|
||||
// URL <-> selection mapping. Runs only when ids change; warnedKeyRef guards
|
||||
// against duplicate warns for the same set (e.g. StrictMode double-invoke).
|
||||
useEffect(() => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const oid of optionIds) {
|
||||
if (seen.has(oid) && !duplicates.includes(oid)) {
|
||||
duplicates.push(oid);
|
||||
}
|
||||
seen.add(oid);
|
||||
}
|
||||
if (duplicates.length === 0) return;
|
||||
const warnKey = duplicates.join(",");
|
||||
if (warnedKeyRef.current === warnKey) return;
|
||||
warnedKeyRef.current = warnKey;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`TailoredContent(id=${id}): duplicate option id(s) detected: ${duplicates
|
||||
.map((d) => `"${d}"`)
|
||||
.join(", ")}. Option ids must be unique.`,
|
||||
);
|
||||
}
|
||||
}, [optionIds, id]);
|
||||
|
||||
// Get the option IDs for URL handling
|
||||
const optionIds = options.map((option) => option.props.id);
|
||||
const updateSelection = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= options.length) return;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set(id, optionIds[index]);
|
||||
// Update URL without reload; derived selectedIndex will follow.
|
||||
router.replace(`?${newParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams, id, optionIds, options.length],
|
||||
);
|
||||
|
||||
// Initialize selected index from URL or default
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => {
|
||||
const urlParam = searchParams.get(id);
|
||||
const indexFromUrl = optionIds.indexOf(urlParam || "");
|
||||
return indexFromUrl >= 0 ? indexFromUrl : defaultOptionIndex;
|
||||
});
|
||||
// No hooks below this point — safe to short-circuit when there are no options.
|
||||
if (options.length === 0) return null;
|
||||
|
||||
// Update URL when selection changes
|
||||
const updateSelection = (index: number) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set(id, optionIds[index]);
|
||||
// Clamp defaultOptionIndex to the valid range.
|
||||
const clampedDefault = Math.min(
|
||||
Math.max(0, defaultOptionIndex),
|
||||
options.length - 1,
|
||||
);
|
||||
|
||||
// Update URL without reload
|
||||
router.replace(`?${newParams.toString()}`, { scroll: false });
|
||||
setSelectedIndex(index);
|
||||
// Derive selectedIndex from the URL on every render so state stays in sync
|
||||
// with navigation (back/forward, external updates to the search param).
|
||||
const urlParam = searchParams.get(id);
|
||||
const indexFromUrl = urlParam ? optionIds.indexOf(urlParam) : -1;
|
||||
const selectedIndex = indexFromUrl >= 0 ? indexFromUrl : clampedDefault;
|
||||
|
||||
const focusTab = (index: number) => {
|
||||
const el = tabRefs.current[index];
|
||||
if (el) el.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
e.preventDefault();
|
||||
updateSelection(index);
|
||||
return;
|
||||
case "ArrowRight": {
|
||||
e.preventDefault();
|
||||
const next = (index + 1) % options.length;
|
||||
updateSelection(next);
|
||||
focusTab(next);
|
||||
return;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prev = (index - 1 + options.length) % options.length;
|
||||
updateSelection(prev);
|
||||
focusTab(prev);
|
||||
return;
|
||||
}
|
||||
case "Home": {
|
||||
e.preventDefault();
|
||||
updateSelection(0);
|
||||
focusTab(0);
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
e.preventDefault();
|
||||
const last = options.length - 1;
|
||||
updateSelection(last);
|
||||
focusTab(last);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const itemCn =
|
||||
@@ -78,41 +171,86 @@ export function TailoredContent({
|
||||
const iconCn =
|
||||
"w-10 h-10 mb-4 top-0 transition-all opacity-20 group-[.selected]:text-indigo-500 group-[.selected]:opacity-60 dark:group-[.selected]:text-indigo-400 dark:group-[.selected]:opacity-60 dark:text-gray-400";
|
||||
|
||||
const tablistId = `tailored-content-tablist-${id}`;
|
||||
const tabId = (optId: string) => `tailored-content-tab-${id}-${optId}`;
|
||||
const panelId = (optId: string) => `tailored-content-panel-${id}-${optId}`;
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("tailored-content-wrapper mt-4", className)}>
|
||||
{header}
|
||||
<div className="flex flex-col md:flex-row gap-3 my-2 w-full">
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option.props.id}
|
||||
className={cn(itemCn, selectedIndex === index && selectedCn)}
|
||||
onClick={() => updateSelection(index)}
|
||||
role="tab"
|
||||
aria-selected={selectedIndex === index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="my-0">
|
||||
{React.isValidElement(option.props.icon) ? (
|
||||
React.cloneElement(
|
||||
option.props.icon as React.ReactElement<any>,
|
||||
{
|
||||
className: cn(iconCn, selectedIndex === index, "my-0"),
|
||||
},
|
||||
)
|
||||
) : (
|
||||
<span className={cn(iconCn, "my-0")} />
|
||||
)}
|
||||
<div
|
||||
id={tablistId}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex flex-col md:flex-row gap-3 my-2 w-full"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={option.props.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
id={tabId(option.props.id)}
|
||||
className={cn(itemCn, isSelected && selectedCn)}
|
||||
onClick={() => updateSelection(index)}
|
||||
onKeyDown={(e) => onKeyDown(e, index)}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
aria-controls={panelId(option.props.id)}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
>
|
||||
<div className="my-0">
|
||||
{React.isValidElement(option.props.icon) ? (
|
||||
(() => {
|
||||
const icon = option.props.icon as IconElement;
|
||||
return React.cloneElement(icon, {
|
||||
className: cn(icon.props?.className, iconCn, "my-0"),
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
<span className={cn(iconCn, "my-0")} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">{option.props.title}</p>
|
||||
<p className="text-xs md:text-sm">{option.props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">{option.props.title}</p>
|
||||
<p className="text-xs md:text-sm">{option.props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{options[selectedIndex]?.props.children}
|
||||
{selectedOption && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={panelId(selectedOption.props.id)}
|
||||
aria-labelledby={tabId(selectedOption.props.id)}
|
||||
>
|
||||
{selectedOption.props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TailoredContent` renders a set of tab-like options and the currently
|
||||
* selected option's content. The selection is persisted in the URL via
|
||||
* `?<id>=<optionId>` so links are shareable.
|
||||
*
|
||||
* Next.js App Router requires `useSearchParams()` to be wrapped in a
|
||||
* `<Suspense>` boundary. The exported component wraps the inner
|
||||
* implementation so consumers don't need to do that themselves.
|
||||
*/
|
||||
export function TailoredContent(props: TailoredContentProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TailoredContentInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,36 +128,84 @@ In addition, some state properties contain a lot of information. Syncing them ba
|
||||
```tsx title="ui/app/page.tsx"
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { useState } from "react";
|
||||
import { useAgent, useCopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Only define the types for state you'll interact with
|
||||
// Define the agent state type, should match the actual state of your agent
|
||||
type AgentState = {
|
||||
question: string;
|
||||
answer: string;
|
||||
// Note: 'resources' is intentionally omitted - it's internal to the agent
|
||||
}
|
||||
|
||||
function YourMainContent() {
|
||||
/* Example usage in a pseudo React component */
|
||||
function YourMainContent() { // [!code highlight]
|
||||
const [inputQuestion, setInputQuestion] = useState("What's the capital of France?");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { agent } = useAgent({
|
||||
agentId: "my_agent",
|
||||
initialState: {
|
||||
question: "How's the weather in SF?",
|
||||
answer: "",
|
||||
}
|
||||
});
|
||||
const { copilotkit } = useCopilotKit();
|
||||
|
||||
const askQuestion = (newQuestion: string) => {
|
||||
agent.setState({ ...agent.state, question: newQuestion });
|
||||
const askQuestion = async (newQuestion: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Update the state with the new question
|
||||
agent.setState({ ...agent.state, question: newQuestion, answer: "" });
|
||||
|
||||
try {
|
||||
// Add a message and trigger the agent to run
|
||||
agent.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: newQuestion,
|
||||
});
|
||||
await copilotkit.runAgent({ agent });
|
||||
} catch (error) {
|
||||
console.error("Error running agent:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<h1>Q&A Assistant</h1>
|
||||
<p><strong>Question:</strong> {agent.state?.question}</p>
|
||||
<p><strong>Answer:</strong> {agent.state?.answer || "Waiting for response..."}</p>
|
||||
<button onClick={() => askQuestion("What's the capital of France?")}>
|
||||
Ask New Question
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputQuestion}
|
||||
onChange={(e) => setInputQuestion(e.target.value)}
|
||||
placeholder="Enter your question..."
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
width: "300px",
|
||||
marginRight: "0.5rem",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ccc"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => askQuestion(inputQuestion)}
|
||||
disabled={isLoading || !inputQuestion.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
backgroundColor: isLoading ? "#ccc" : "#0070f3",
|
||||
color: "white",
|
||||
cursor: isLoading ? "not-allowed" : "pointer"
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Thinking..." : "Ask Question"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<p><strong>Question:</strong> {agent.state?.question || "(none yet)"}</p>
|
||||
<p><strong>Answer:</strong> {agent.state?.answer || (isLoading ? "Thinking..." : "Waiting for question...")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ icon: "lucide/Play"
|
||||
hideTOC: true
|
||||
---
|
||||
|
||||
import {
|
||||
TailoredContent,
|
||||
TailoredContentOption,
|
||||
} from "@/components/react/tailored-content.tsx";
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
@@ -13,132 +18,442 @@ hideTOC: true
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
<TailoredContent
|
||||
className="step"
|
||||
id="agent-spec-quickstart-path"
|
||||
header={
|
||||
<div>
|
||||
<p className="text-xl font-semibold">Choose your starting point</p>
|
||||
<p className="text-base">
|
||||
You can either start fresh with our starter template or connect CopilotKit to an existing Agent Spec agent.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TailoredContentOption
|
||||
id="starter"
|
||||
title="Start from scratch"
|
||||
description="Get started quickly with our ready-to-go Agent Spec starter."
|
||||
>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. Here's how to install it:
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. You will need it to activate your agent environment in the starter. Here's how to install it:
|
||||
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui/integrations/agent-spec/python
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui
|
||||
git sparse-checkout set integrations/agent-spec/python
|
||||
cd integrations/agent-spec/python
|
||||
```
|
||||
|
||||
This will setup AG-UI integration which will be used by the starter repository to install starter templates agent. This is only for agent environment setup
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
|
||||
or you can also use uv (In case you encounter any issues with pip installation)
|
||||
|
||||
```bash
|
||||
uv add pyagentspec[langgraph]
|
||||
uv add wayflowcore
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Scaffold the UI
|
||||
|
||||
Use our starter repo template: https://github.com/CopilotKit/with-agent-spec. It includes an example definition of an Agent Spec agent [here](https://github.com/CopilotKit/with-agent-spec/blob/main/agent/src/agentspec_agent.py). Run the following commands
|
||||
|
||||
Go to your root directory
|
||||
|
||||
```bash
|
||||
# If you are in the path integrations/agent-spec/python, then run the following command to go to root
|
||||
cd ../../../../
|
||||
```
|
||||
Clone the starter template
|
||||
|
||||
```bash
|
||||
# If you are in the path integrations/agent-spec/python, then run the following command to go to root
|
||||
git clone https://github.com/CopilotKit/with-agent-spec.git
|
||||
cd with-agent-spec
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Install Dependencies
|
||||
|
||||
Run the following commands to install your dependencies in the start repository
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Run your project
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or npm run dev / yarn dev / bun dev
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### 🎉 Start chatting!
|
||||
|
||||
Your AI agent is now ready to use! Navigate to `localhost:3000` and try asking it some questions:
|
||||
|
||||
```
|
||||
Can you tell me a joke?
|
||||
```
|
||||
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
Can you help me understand AI?
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
What do you think about React?
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
<Accordions className="mb-4">
|
||||
<Accordion title="Troubleshooting">
|
||||
- If you're having connection issues, try using `0.0.0.0` or `127.0.0.1` instead of `localhost`
|
||||
- Make sure your agent is running on port 8000
|
||||
- Check that your OpenAI API key is correctly set
|
||||
- Verify that the `@ag-ui/client` package is installed in your frontend
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
</TailoredContentOption>
|
||||
<TailoredContentOption
|
||||
id="bring-your-own"
|
||||
title="Use an existing agent"
|
||||
description="I already have an Agent Spec setup and want to connect CopilotKit UI."
|
||||
>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. Here's how to install it:
|
||||
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui
|
||||
git sparse-checkout set integrations/agent-spec/python
|
||||
cd integrations/agent-spec/python
|
||||
```
|
||||
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
|
||||
or you can also use uv (In case you encounter any issues with pip installation)
|
||||
|
||||
```bash
|
||||
uv add pyagentspec[langgraph]
|
||||
uv add wayflowcore
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Set up your Agent
|
||||
|
||||
Go to ag_ui_agentspec directory
|
||||
|
||||
```bash
|
||||
cd ag_ui_agentspec
|
||||
```
|
||||
|
||||
create a main.py file in the ag_ui_agentspec directory
|
||||
|
||||
|
||||
```bash
|
||||
#file path: ag-ui/integrations/agent-spec/python/ag_ui_agentspec/main.py
|
||||
from pyagentspec.agent import Agent
|
||||
from pyagentspec.llms import OpenAiCompatibleConfig
|
||||
from pyagentspec.serialization import AgentSpecSerializer
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_agentspec.agent import AgentSpecAgent
|
||||
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint
|
||||
import uvicorn
|
||||
|
||||
agentspec_agent = Agent(
|
||||
name="AgentSpecAgent",
|
||||
description="A starter Agent that can call tools.",
|
||||
system_prompt="You are a helpful assistant, named Specky, that speaks a lot.",
|
||||
llm_config=OpenAiCompatibleConfig(
|
||||
name="my-llm",
|
||||
model_id="gpt-5.4",
|
||||
url="https://api.openai.com/v1",
|
||||
),
|
||||
)
|
||||
|
||||
agent_spec_config = AgentSpecSerializer().to_json(agentspec_agent)
|
||||
|
||||
|
||||
#OR you can specify your own agent_spec_config like below
|
||||
#agent_spec_config = <loaded json/yaml string of your Agent Spec agent>
|
||||
|
||||
runtime = "langgraph" # or "wayflow"
|
||||
|
||||
app = FastAPI()
|
||||
agent = AgentSpecAgent(agent_spec_config=agent_spec_config, runtime=runtime)
|
||||
add_agentspec_fastapi_endpoint(app, agentspec_agent=agent, path="/")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Create your frontend
|
||||
|
||||
CopilotKit works with any React-based frontend. We'll use Next.js for this example.
|
||||
Go to your root directory, then create a Next.js project
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest my-copilot-app
|
||||
cd my-copilot-app
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Install CopilotKit packages
|
||||
|
||||
```npm
|
||||
npm install @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime @ag-ui/client
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Setup Copilot Runtime
|
||||
|
||||
Create an API route to connect CopilotKit to your Pydantic AI agent:
|
||||
|
||||
```tsx title="app/api/copilotkit/route.ts"
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const serviceAdapter = new ExperimentalEmptyAdapter();
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
my_agent: new HttpAgent({ url: "http://localhost:8000/" }),
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter,
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(req);
|
||||
};
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure CopilotKit Provider
|
||||
|
||||
Wrap your application with the CopilotKit provider:
|
||||
|
||||
```tsx title="app/layout.tsx"
|
||||
import { CopilotKit } from "@copilotkit/react-core"; // [!code highlight]
|
||||
import "@copilotkit/react-ui/v2/styles.css";
|
||||
import './globals.css';
|
||||
|
||||
// ...
|
||||
|
||||
export default function RootLayout({ children }: {children: React.ReactNode}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{/* [!code highlight:3] */}
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="my_agent">
|
||||
{children}
|
||||
</CopilotKit>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Add the chat interface
|
||||
|
||||
Add the CopilotSidebar component to your page:
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2"; // [!code highlight:1]
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Your App</h1>
|
||||
{/* [!code highlight:1] */}
|
||||
<CopilotSidebar />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Start your agent
|
||||
|
||||
From your agent directory, start the agent server:
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cd ag-ui/integrations/agent-spec/python/ag_ui_agentspec
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Your agent will be available at `http://localhost:8000`.
|
||||
</Step>
|
||||
<Step>
|
||||
### Start your UI
|
||||
|
||||
In a separate terminal, navigate to your frontend directory and start the development server:
|
||||
|
||||
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn', 'bun']}>
|
||||
<Tab value="npm">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
npm run dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="pnpm">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
pnpm dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="yarn">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
yarn dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="bun">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
bun dev
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step>
|
||||
### 🎉 Start chatting!
|
||||
|
||||
Your AI agent is now ready to use! Navigate to `localhost:3000` and try asking it some questions:
|
||||
|
||||
```
|
||||
Can you tell me a joke?
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Scaffold the UI
|
||||
|
||||
Use our starter repo template: https://github.com/CopilotKit/with-agent-spec. It includes an example definition of an Agent Spec agent [here](https://github.com/CopilotKit/with-agent-spec/blob/main/agent/src/agentspec_agent.py).
|
||||
|
||||
#### Minimal starter Agent Spec agent definition
|
||||
|
||||
```python agentspec_agent.py
|
||||
from pyagentspec.agent import Agent
|
||||
from pyagentspec.llms import OpenAiCompatibleConfig
|
||||
from pyagentspec.serialization import AgentSpecSerializer
|
||||
|
||||
agentspec_agent = Agent(
|
||||
name="AgentSpecAgent",
|
||||
description="A starter Agent that can call tools.",
|
||||
system_prompt="You are a helpful assistant, named Specky, that speaks a lot.",
|
||||
llm_config=OpenAiCompatibleConfig(
|
||||
name="my-llm",
|
||||
model_id="gpt-5.4",
|
||||
url="https://api.openai.com/v1",
|
||||
),
|
||||
)
|
||||
|
||||
agent_spec_config = AgentSpecSerializer().to_json(agentspec_agent)
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Add a minimal FastAPI endpoint (backend)
|
||||
|
||||
Create a FastAPI app that loads your Agent Spec file and exposes an AG‑UI FastAPI endpoint. Replace the `runtime` to match your adapter (`langgraph` or `wayflow`).
|
||||
|
||||
```python src/main.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_agentspec.agent import AgentSpecAgent
|
||||
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint
|
||||
|
||||
agent_spec_config = <loaded json/yaml string of your Agent Spec agent>
|
||||
runtime = "langgraph" # or "wayflow"
|
||||
|
||||
app = FastAPI()
|
||||
agent = AgentSpecAgent(agent_spec_config=agent_spec_config, runtime=runtime)
|
||||
add_agentspec_fastapi_endpoint(app, agentspec_agent=agent, path="/")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
Can you help me understand AI?
|
||||
```
|
||||
|
||||
Here, we use the `add_agentspec_fastapi_endpoint` utility from the integration package. It sets up the endpoint and the wiring of Agent Spec Tracing events to AG-UI events.
|
||||
|
||||
To run the backend agent:
|
||||
|
||||
```bash
|
||||
uv run src/main.py
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Connect the UI to your frontend server
|
||||
|
||||
Make sure the frontend UI server knows what host/port the backend agent is running on. In this tutorial, we use http://localhost:8000/ as the host/port.
|
||||
</Step>
|
||||
<Step>
|
||||
### Run Next.js
|
||||
|
||||
From the root directory of [our starter repo](https://github.com/CopilotKit/with-agent-spec/), run:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or npm run dev / yarn dev / bun dev
|
||||
What do you think about React?
|
||||
```
|
||||
|
||||
Note that this command also launches the agent backend in `agent/src`. Now, open http://localhost:3000 and start chatting with your agent.
|
||||
<Accordions className="mb-4">
|
||||
<Accordion title="Troubleshooting">
|
||||
- If you're having connection issues, try using `0.0.0.0` or `127.0.0.1` instead of `localhost`
|
||||
- Make sure your agent is running on port 8000
|
||||
- Check that your OpenAI API key is correctly set
|
||||
- Verify that the `@ag-ui/client` package is installed in your frontend
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
</Step>
|
||||
</TailoredContentOption>
|
||||
</TailoredContent>
|
||||
</Steps>
|
||||
|
||||
## Tools and tool registry
|
||||
|
||||
@@ -39,6 +39,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
Configure your Strands agent to maintain state. Here's an example that tracks searches:
|
||||
|
||||
```python title="agent/main.py"
|
||||
import os
|
||||
import json
|
||||
from ag_ui_strands import StrandsAgent, StrandsAgentConfig, ToolBehavior, create_strands_app
|
||||
from strands import Agent, tool
|
||||
@@ -89,7 +90,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
|
||||
agui_agent = StrandsAgent(
|
||||
agent=strands_agent,
|
||||
name="searchAgent",
|
||||
name="strands_agent",
|
||||
description="A helpful assistant for storing searches",
|
||||
config=config,
|
||||
)
|
||||
@@ -122,7 +123,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
// [!code highlight:13]
|
||||
// styles omitted for brevity
|
||||
useAgent({
|
||||
agentId: "searchAgent",
|
||||
agentId: "strands_agent",
|
||||
render: ({ state }) => (
|
||||
<div>
|
||||
{state.searches?.map((search, index) => (
|
||||
@@ -168,7 +169,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
|
||||
// [!code highlight:3]
|
||||
const { agent } = useAgent({
|
||||
agentId: "searchAgent",
|
||||
agentId: "strands_agent",
|
||||
})
|
||||
|
||||
// ...
|
||||
|
||||
@@ -96,7 +96,7 @@ state updates, you can reflect these updates natively in your application.
|
||||
function YourMainContent() {
|
||||
// [!code highlight:5]
|
||||
const { agent } = useAgent({
|
||||
agentId: "languageAgent",
|
||||
agentId: "strands_agent",
|
||||
// optionally provide a type-safe initial state
|
||||
initialState: { language: "spanish" }
|
||||
});
|
||||
@@ -143,7 +143,7 @@ function YourMainContent() {
|
||||
// ...
|
||||
// [!code highlight:7]
|
||||
useAgent({
|
||||
agentId: "languageAgent",
|
||||
agentId: "strands_agent",
|
||||
render: ({ state }) => {
|
||||
if (!state.language) return null;
|
||||
return <div>Language: {state.language}</div>;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useAgent } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
function TaskBoard() {
|
||||
// [!code highlight:3]
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
// Read state set by the agent // [!code highlight]
|
||||
@@ -60,7 +60,7 @@ import { useAgent } from "@copilotkit/react-core/v2";
|
||||
|
||||
function SettingsPanel() {
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme: string) => {
|
||||
@@ -99,7 +99,7 @@ import { useAgent } from "@copilotkit/react-core/v2";
|
||||
|
||||
function TodoApp() {
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
const todos = (agent.state.todos as any[]) ?? [];
|
||||
|
||||
@@ -29,7 +29,7 @@ First, pass the configuration properties as you would like to receive them in th
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useAgent } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
|
||||
import { useEffect } from "react";
|
||||
function YourMainContent() {
|
||||
// ...
|
||||
|
||||
@@ -40,16 +40,18 @@ function YourMainContent() {
|
||||
|
||||
// Pass configuration when running the agent
|
||||
// [!code highlight:8]
|
||||
agent.runAgent({
|
||||
forwardedProps: {
|
||||
config: {
|
||||
configurable: {
|
||||
authToken: 'example-token'
|
||||
},
|
||||
recursion_limit: 50,
|
||||
useEffect(() => {
|
||||
agent.runAgent({
|
||||
forwardedProps: {
|
||||
config: {
|
||||
configurable: {
|
||||
authToken: 'example-token'
|
||||
},
|
||||
recursion_limit: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ...
|
||||
|
||||
|
||||
@@ -111,14 +111,12 @@ agent = create_agent(
|
||||
<Step>
|
||||
### Configure the runtime (TypeScript)
|
||||
|
||||
Enable A2UI in your CopilotRuntime:
|
||||
Enable A2UI in your CopilotRuntime. The middleware auto-detects A2UI operations in any tool result, so no tool injection is needed here — the agent's `search_flights` tool returns them directly.
|
||||
|
||||
```typescript title="app/api/copilotkit/route.ts"
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: myAgent },
|
||||
a2ui: {
|
||||
injectA2UITool: true,
|
||||
},
|
||||
a2ui: {},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
@@ -73,6 +73,7 @@ Use state rendering when you want to:
|
||||
```python title="agent.py"
|
||||
import asyncio
|
||||
from copilotkit.langgraph import copilotkit_emit_state # [!code highlight]
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
async def chat_node(state: AgentState, config: RunnableConfig):
|
||||
state["searches"] = [
|
||||
|
||||
@@ -176,6 +176,10 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
|
||||
To do this, we'll use the `useInterrupt` hook, give it a component to render, and then call `resolve` with the user's response.
|
||||
|
||||
<Callout type="warn">
|
||||
**`agentId` must match a runtime-registered agent.** If you omit `agentId`, the hook assumes `"default"`. If the IDs don't match, the interrupt will never fire.
|
||||
</Callout>
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
// ...
|
||||
@@ -185,6 +189,7 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
// [!code highlight:15]
|
||||
// styles omitted for brevity
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
render: ({ event, resolve }) => (
|
||||
<div>
|
||||
<p>{event.value}</p>
|
||||
@@ -326,6 +331,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
// ...
|
||||
// [!code highlight:13]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'ask',
|
||||
render: ({ event, resolve }) => (
|
||||
<AskComponent question={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -333,6 +339,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
});
|
||||
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'approval',
|
||||
render: ({ event, resolve }) => (
|
||||
<ApproveComponent content={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -371,6 +378,7 @@ const YourMainContent = () => {
|
||||
// styles omitted for brevity
|
||||
// [!code highlight:28]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
handler: async ({ result, event, resolve }) => {
|
||||
const { department } = await getUserByEmail(userEmail)
|
||||
if (event.value.accessDepartment === department || department === 'admin') {
|
||||
|
||||
@@ -176,6 +176,10 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
|
||||
To do this, we'll use the `useInterrupt` hook, give it a component to render, and then call `resolve` with the user's response.
|
||||
|
||||
<Callout type="warn">
|
||||
**`agentId` must match a runtime-registered agent.** If you omit `agentId`, the hook assumes `"default"`. If the IDs don't match, the interrupt will never fire.
|
||||
</Callout>
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
// ...
|
||||
@@ -185,6 +189,7 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
// [!code highlight:15]
|
||||
// styles omitted for brevity
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
render: ({ event, resolve }) => (
|
||||
<div>
|
||||
<p>{event.value}</p>
|
||||
@@ -326,6 +331,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
// ...
|
||||
// [!code highlight:13]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'ask',
|
||||
render: ({ event, resolve }) => (
|
||||
<AskComponent question={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -333,6 +339,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
});
|
||||
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'approval',
|
||||
render: ({ event, resolve }) => (
|
||||
<ApproveComponent content={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -371,6 +378,7 @@ const YourMainContent = () => {
|
||||
// styles omitted for brevity
|
||||
// [!code highlight:28]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
handler: async ({ result, event, resolve }) => {
|
||||
const { department } = await getUserByEmail(userEmail)
|
||||
if (event.value.accessDepartment === department || department === 'admin') {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
title: Readables
|
||||
title: Agent App Context
|
||||
icon: "lucide/BookA"
|
||||
description: Share app specific context with your agent.
|
||||
---
|
||||
|
||||
## What is this?
|
||||
|
||||
One of the most common use cases for CopilotKit is to register app state and context using `useCopilotReadble`.
|
||||
This way, you can notify your agent of what is going in your app in real time.
|
||||
One of the most common use cases for CopilotKit is to register app state and context using `useAgentContext`.
|
||||
This way, you can notify your agent of what is going on in your app in real time.
|
||||
|
||||
## When should I use this?
|
||||
|
||||
You can use this when you want to provide the user with feedback about what your working memory. As your agent's
|
||||
You can use this when you want to provide the user with feedback about what is in your working memory. As your agent's
|
||||
state updates, you can reflect these updates natively in your application.
|
||||
|
||||
Some examples might be: the current user, the current page, etc. This be shared with your agent in real time.
|
||||
Some examples might be: the current user, the current page, etc. This can be shared with your agent in real time.
|
||||
|
||||
## Implementation
|
||||
<Steps>
|
||||
<Step>
|
||||
### Wrap your data in a readable
|
||||
### Share data with your agent
|
||||
|
||||
The [`useAgentContext` hook](/reference/v2/hooks/useAgentContext) is used to add data as context to the Copilot.
|
||||
|
||||
```tsx title="YourComponent.tsx" showLineNumbers {1, 7-10}
|
||||
```tsx title="YourComponent.tsx" showLineNumbers
|
||||
"use client" // only necessary if you are using Next.js with the App Router. // [!code highlight]
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
import { useState } from 'react';
|
||||
@@ -36,7 +36,7 @@ Some examples might be: the current user, the current page, etc. This be shared
|
||||
{ id: 3, name: "Bob Wilson", role: "Product Manager" }
|
||||
]);
|
||||
|
||||
// Define Copilot readable state
|
||||
// Define agent context
|
||||
// [!code highlight:4]
|
||||
useAgentContext({
|
||||
description: "The current user's colleagues",
|
||||
@@ -58,18 +58,22 @@ Some examples might be: the current user, the current page, etc. This be shared
|
||||
You can read more about it [here](https://mastra.ai/en/docs/agents/runtime-context)
|
||||
|
||||
```tsx title="agent.ts"
|
||||
export const colleaguesContactorAgent = new Agent({
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
|
||||
export const colleaguesContactAgent = new Agent({
|
||||
id: "colleague-agent",
|
||||
name: "Colleagues contact Agent",
|
||||
model: openai("gpt-5.4"),
|
||||
model: openai("gpt-4o"),
|
||||
// Use the injected runtime context
|
||||
// [!code highlight:9]
|
||||
instructions: ({ runtimeContext }) => {
|
||||
// AG-UI context is an array of items, the specific context can be grabbed by filtering
|
||||
const aguiContext = runtimeContext.get('ag-ui')?.context
|
||||
const colleaguesContextItem = aguiContext.find(contextItem => contextItem.description === 'The current user\'s colleagues"')
|
||||
const aguiContext = runtimeContext.get('ag-ui') as { context: Array<{ description: string; value: unknown }> } | undefined;
|
||||
const colleaguesContextItem = aguiContext?.context?.find((contextItem: { description: string; value: unknown }) => contextItem.description === "The current user's colleagues")
|
||||
return `
|
||||
You are a helpful assistant that can help emailing colleagues.
|
||||
The user's colleagues are: ${colleaguesContextItem.value}
|
||||
The user's colleagues are: ${JSON.stringify(colleaguesContextItem?.value, null, 2)}
|
||||
`
|
||||
},
|
||||
// ... Everything else used to configure your agent
|
||||
|
||||
@@ -249,7 +249,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
}
|
||||
}
|
||||
|
||||
PREDICT_STATE_CONFIG: Dict[str, Dict[str, str]] = {
|
||||
PREDICT_STATE_CONFIG: dict[str, dict[str, str]] = {
|
||||
"searches": {
|
||||
"tool": "update_searches",
|
||||
"tool_argument": "searches",
|
||||
|
||||
@@ -63,7 +63,7 @@ You can use this when you need to update agent state from your application — f
|
||||
}
|
||||
}
|
||||
|
||||
PREDICT_STATE_CONFIG: Dict[str, Dict[str, str]] = {
|
||||
PREDICT_STATE_CONFIG: dict[str, dict[str, str]] = {
|
||||
"language": {"tool": "update_language", "tool_argument": "language"}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,18 +44,20 @@ A2UI specifications can be rendered on web, mobile, or any other platform, makin
|
||||
|
||||
### Backend
|
||||
|
||||
Enable A2UI in `CopilotRuntime` by passing `a2ui: true`:
|
||||
Enable A2UI in `CopilotRuntime` and inject a rendering tool (`render_a2ui`) into your agent so it can produce A2UI surfaces:
|
||||
|
||||
```ts title="app/api/copilotkit/route.ts"
|
||||
import { CopilotRuntime } from "@copilotkit/runtime";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: myAgent },
|
||||
a2ui: true,
|
||||
a2ui: {
|
||||
injectA2UITool: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This automatically applies `A2UIMiddleware` to all registered agents. Pass an object instead of `true` to customise behaviour — for example, to scope it to specific agents: `a2ui: { agents: ["my-agent"] }`.
|
||||
This applies `A2UIMiddleware` to all registered agents and adds `render_a2ui` to the agent's tool list, along with usage guidelines so the LLM knows how to call it. Scope to specific agents with `a2ui: { injectA2UITool: true, agents: ["my-agent"] }`.
|
||||
|
||||
Once configured, any A2UI output returned from your agent will automatically be rendered in the chat interface — no additional frontend code required.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Rendering tools in the UI is useful when you want to provide the user with feedb
|
||||
Use the `useRenderTool` hook to render tool calls in the UI. The name must match the name of the tool defined in your agent.
|
||||
|
||||
<Callout type="info" title="Important">
|
||||
In order to render a tool call in the UI, the name must match the name of the tool.
|
||||
In order to render a tool call in the UI, the name must match the name of the tool. [Learn more](/built-in-agent/server-tools).
|
||||
</Callout>
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
|
||||
@@ -11,24 +11,22 @@ This is a starter template for building AI agents that use [A2UI](https://a2ui.o
|
||||
- uv
|
||||
- Node.js 20+
|
||||
- Any of the following package managers:
|
||||
- pnpm (recommended)
|
||||
- npm
|
||||
- yarn
|
||||
- bun
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -38,7 +36,7 @@ bun install
|
||||
|
||||
> **Note:** This will automatically setup the Python environment as well.
|
||||
>
|
||||
> If you have manual isseus, you can run:
|
||||
> If you have manual issues, you can run:
|
||||
>
|
||||
> ```sh
|
||||
> npm run install:agent
|
||||
@@ -55,12 +53,12 @@ GEMENI_API_KEY=sk-...your-openai-key-here...
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
npm run dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -77,15 +75,14 @@ The following scripts can also be run using your preferred package manager:
|
||||
- `dev` - Starts both UI and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the PydanticAI agent server
|
||||
- `dev:agent` - Starts only the A2A agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## Documentation
|
||||
|
||||
The main UI component is in `src/app/page.tsx`, but most of the UI comes from from the agent in the form of A2UI declarative components. To see and edit the components it can generate, look in `agent/prompt_builder.py`.
|
||||
The main UI component is in `app/page.tsx`, but most of the UI comes from from the agent in the form of A2UI declarative components. To see and edit the components it can generate, look in `agent/prompt_builder.py`.
|
||||
To generate new components, try the [A2UI Composer](https://a2ui-editor.ag-ui.com)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
|
||||
"dev:debug": "LOG_LEVEL=debug npm run dev",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts/run-agent.bat",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"dev:ui": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
|
||||
@@ -42,11 +42,6 @@ next-env.d.ts
|
||||
|
||||
.mastra/
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
# python
|
||||
agent/venv/
|
||||
|
||||
@@ -8,24 +8,22 @@ This is a starter template for building AI agents using Google's [ADK](https://g
|
||||
- Python 3.12+
|
||||
- Google Makersuite API Key (for the ADK agent) (see https://makersuite.google.com/app/apikey)
|
||||
- Any of the following package managers:
|
||||
- pnpm (recommended)
|
||||
- npm
|
||||
- yarn
|
||||
- bun
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -36,12 +34,12 @@ bun install
|
||||
2. Install Python dependencies for the ADK agent:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run install:agent
|
||||
|
||||
# Using pnpm
|
||||
pnpm install:agent
|
||||
|
||||
# Using npm
|
||||
npm run install:agent
|
||||
|
||||
# Using yarn
|
||||
yarn install:agent
|
||||
|
||||
@@ -66,12 +64,12 @@ export GOOGLE_API_KEY="your-google-api-key-here"
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
npm run dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -91,7 +89,6 @@ The following scripts can also be run using your preferred package manager:
|
||||
- `dev:agent` - Starts only the ADK agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -3,7 +3,8 @@ services:
|
||||
image: ghcr.io/copilotkit/aimock:latest
|
||||
volumes:
|
||||
- ./fixtures:/fixtures:ro
|
||||
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
|
||||
command:
|
||||
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
|
||||
|
||||
agent:
|
||||
build:
|
||||
@@ -66,7 +67,7 @@ services:
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium --with-deps 2>/dev/null && npx playwright test starter-smoke --reporter=list",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
|
||||
]
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
|
||||
"dev:debug": "LOG_LEVEL=debug npm run dev",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts/run-agent.bat",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"dev:ui": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
|
||||
@@ -43,11 +43,6 @@ next-env.d.ts
|
||||
|
||||
.mastra/
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
# python
|
||||
venv
|
||||
|
||||
@@ -11,12 +11,10 @@ This is a starter template for building AI agents using Agent Spec and CopilotKi
|
||||
- uv
|
||||
- Node.js 20+
|
||||
- Any of the following package managers:
|
||||
- pnpm (recommended)
|
||||
- npm
|
||||
- yarn
|
||||
- bun
|
||||
|
||||
> Note: This repository ignores lock files to avoid conflicts between different package managers. Each developer can generate a lock file locally with their preferred package manager.
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -25,12 +23,12 @@ Before installing, please clone the [AG-UI repository](https://github.com/ag-ui-
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -64,12 +62,12 @@ The backend loads this `.env` automatically (via `python-dotenv`). You can also
|
||||
3. Start the development servers:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
npm run dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -83,10 +81,10 @@ To run only the UI or only the backend:
|
||||
|
||||
```bash
|
||||
# Only UI
|
||||
pnpm run dev:ui
|
||||
npm run dev:ui
|
||||
|
||||
# Only backend
|
||||
pnpm run dev:agent
|
||||
npm run dev:agent
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -107,7 +105,6 @@ You can run these with any package manager:
|
||||
- `dev:agent` - Starts only the Agent Spec FastAPI server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## Documentation
|
||||
@@ -134,10 +131,10 @@ If A2UI cards (e.g. those with bottom action buttons) get clipped in the chat UI
|
||||
To apply it locally (this edits `node_modules` and will be overwritten by reinstalling dependencies):
|
||||
|
||||
```bash
|
||||
pnpm patch:ui
|
||||
npm run patch:ui
|
||||
```
|
||||
|
||||
After copying, restart `pnpm dev`.
|
||||
After copying, restart `npm run dev`.
|
||||
|
||||
### Custom message key warning (temporary workaround)
|
||||
|
||||
@@ -148,10 +145,10 @@ If you see React warnings about duplicate keys related to custom message renderi
|
||||
To apply it locally (this edits `node_modules` and will be overwritten by reinstalling dependencies):
|
||||
|
||||
```bash
|
||||
pnpm patch:ui
|
||||
npm run patch:ui
|
||||
```
|
||||
|
||||
After copying, restart `pnpm dev`.
|
||||
After copying, restart `npm run dev`.
|
||||
|
||||
### Agent Connection Issues
|
||||
|
||||
|
||||
@@ -42,11 +42,6 @@ next-env.d.ts
|
||||
|
||||
.mastra/
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
# python
|
||||
agent/venv/
|
||||
|
||||
@@ -8,24 +8,22 @@ This is a starter template for building AI agents using [Agno](https://agno.com)
|
||||
- Python 3.12+
|
||||
- OpenAI API Key (for the Agno agent)
|
||||
- Any of the following package managers:
|
||||
- pnpm (recommended)
|
||||
- npm
|
||||
- yarn
|
||||
- bun
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -50,12 +48,12 @@ echo "OPENAI_API_KEY=your-openai-api-key-here" > agent/.env
|
||||
3. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
npm run dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -70,12 +68,10 @@ This will start both the UI and agent servers concurrently.
|
||||
The following scripts can also be run using your preferred package manager:
|
||||
|
||||
- `dev` - Starts both UI and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the Agno agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
@@ -3,7 +3,8 @@ services:
|
||||
image: ghcr.io/copilotkit/aimock:latest
|
||||
volumes:
|
||||
- ./fixtures:/fixtures:ro
|
||||
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
|
||||
command:
|
||||
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
|
||||
|
||||
agent:
|
||||
build:
|
||||
@@ -66,7 +67,7 @@ services:
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium --with-deps 2>/dev/null && npx playwright test starter-smoke --reporter=list",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
|
||||
]
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -40,8 +40,3 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
# CopilotKit <> CrewAI Flow Starter
|
||||
# CopilotKit <> CrewAI Crew Starter
|
||||
|
||||
This is a starter template for building AI agents using [CrewAI Flows](https://docs.crewai.com/en/concepts/flows) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated CrewAI Flow agent to be built on top of.
|
||||
This is a starter template for building AI agents using [CrewAI Crews](https://docs.crewai.com/en/concepts/crews) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated CrewAI Crew agent to be built on top of.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- Any of the following package managers:
|
||||
- [pnpm](https://pnpm.io/installation) (recommended)
|
||||
- npm
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
- OpenAI API Key (for the CrewAI Flow agent)
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
- OpenAI API Key (for the CrewAI Crew agent)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -45,12 +43,12 @@ echo "OPENAI_API_KEY=your-openai-api-key-here" > .env
|
||||
3. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -65,9 +63,8 @@ This will start both the UI and agent servers concurrently.
|
||||
The following scripts can also be run using your preferred package manager:
|
||||
|
||||
- `dev` - Starts both UI and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the CrewAI Flow agent server
|
||||
- `dev:agent` - Starts only the CrewAI Crew agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
@@ -80,12 +77,12 @@ The main UI component is in `src/app/page.tsx`. You can:
|
||||
- Modify the theme colors and styling
|
||||
- Add new frontend actions
|
||||
- Utilize shared-state
|
||||
- Customize your user-interface for interactin with CrewAI Flow
|
||||
- Customize your user-interface for interacting with CrewAI Crews
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
|
||||
- [CrewAI Flow Documentation](https://docs.crewai.com/en/concepts/flows) - Learn more about CrewAI Flow and its features
|
||||
- [CrewAI Crews Documentation](https://docs.crewai.com/en/concepts/crews) - Learn more about CrewAI Crews and its features
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
|
||||
|
||||
## Contributing
|
||||
@@ -102,6 +99,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
||||
|
||||
If you see "I'm having trouble connecting to my tools", make sure:
|
||||
|
||||
1. The CrewAI Flow agent is running on port 8000
|
||||
1. The CrewAI Crew agent is running on port 8000
|
||||
2. Your OpenAI API key is set correctly
|
||||
3. Both servers started successfully
|
||||
|
||||
@@ -3,7 +3,8 @@ services:
|
||||
image: ghcr.io/copilotkit/aimock:latest
|
||||
volumes:
|
||||
- ./fixtures:/fixtures:ro
|
||||
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
|
||||
command:
|
||||
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
|
||||
|
||||
agent:
|
||||
build:
|
||||
@@ -66,7 +67,7 @@ services:
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium --with-deps 2>/dev/null && npx playwright test starter-smoke --reporter=list",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
|
||||
]
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -40,8 +40,3 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
@@ -8,25 +8,23 @@ This is a starter template for building AI agents using [CrewAI Flows](https://d
|
||||
- Python 3.10+
|
||||
- [uv](https://docs.astral.sh/uv/) - Fast Python package installer and resolver
|
||||
- Any of the following package managers:
|
||||
- [pnpm](https://pnpm.io/installation) (recommended)
|
||||
- npm
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
- OpenAI API Key (for the CrewAI Flow agent)
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -46,12 +44,12 @@ echo "OPENAI_API_KEY=your-openai-api-key-here" > .env
|
||||
3. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -66,12 +64,10 @@ This will start both the UI and agent servers concurrently.
|
||||
The following scripts can also be run using your preferred package manager:
|
||||
|
||||
- `dev` - Starts both UI and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the CrewAI Flow agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent using `uv`
|
||||
|
||||
## Documentation
|
||||
@@ -81,7 +77,7 @@ The main UI component is in `src/app/page.tsx`. You can:
|
||||
- Modify the theme colors and styling
|
||||
- Add new frontend actions
|
||||
- Utilize shared-state
|
||||
- Customize your user-interface for interactin with CrewAI Flow
|
||||
- Customize your user-interface for interacting with CrewAI Flow
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
||||
@@ -41,11 +41,6 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
|
||||
@@ -8,25 +8,23 @@ This is a starter template for building AI agents using [LangGraph](https://www.
|
||||
- Python 3.8+
|
||||
- Poetry 2+
|
||||
- Any of the following package managers:
|
||||
- [pnpm](https://pnpm.io/installation) (recommended)
|
||||
- npm
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
- OpenAI API Key (for the LangGraph agent)
|
||||
|
||||
> **Note:** This repository ignores lock files (package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb) to avoid conflicts between different package managers. Each developer should generate their own lock file using their preferred package manager. After that, make sure to delete it from the .gitignore.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using pnpm (recommended)
|
||||
pnpm install
|
||||
|
||||
# Using npm
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
@@ -37,12 +35,12 @@ bun install
|
||||
2. Install Python dependencies for the LangGraph agent:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run install:agent
|
||||
|
||||
# Using pnpm
|
||||
pnpm install:agent
|
||||
|
||||
# Using npm
|
||||
npm run install:agent
|
||||
|
||||
# Using yarn
|
||||
yarn install:agent
|
||||
|
||||
@@ -59,12 +57,12 @@ echo 'OPENAI_API_KEY=your-openai-api-key-here' > agent/.env
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using npm
|
||||
npm run dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
@@ -84,7 +82,6 @@ The following scripts can also be run using your preferred package manager:
|
||||
- `dev:agent` - Starts only the LangGraph agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs ESLint for code linting
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -6,7 +6,8 @@ services:
|
||||
image: ghcr.io/copilotkit/aimock:latest
|
||||
volumes:
|
||||
- ./fixtures:/fixtures:ro
|
||||
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
|
||||
command:
|
||||
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
|
||||
|
||||
agent:
|
||||
build:
|
||||
@@ -70,7 +71,7 @@ services:
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium --with-deps 2>/dev/null && npx playwright test starter-smoke --reporter=list",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
|
||||
]
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -3,5 +3,4 @@ node_modules
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
apps/web/node_modules
|
||||
apps/agent/node_modules
|
||||
agent/node_modules
|
||||
|
||||
@@ -17,9 +17,6 @@ node_modules
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -44,8 +41,6 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
# Stage 1: Build everything in monorepo
|
||||
# Stage 1: Build the Next.js frontend
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/agent/package.json ./apps/agent/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
COPY package.json ./
|
||||
RUN npm install --ignore-scripts
|
||||
|
||||
COPY apps/ ./apps/
|
||||
COPY turbo.json ./
|
||||
COPY app/ ./app/
|
||||
COPY public/ ./public/
|
||||
COPY next.config.ts tsconfig.json postcss.config.mjs ./
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
RUN pnpm --filter web build
|
||||
RUN npx next build
|
||||
|
||||
# Stage 2: Production image
|
||||
FROM node:20-slim AS runner
|
||||
@@ -23,15 +21,14 @@ RUN npm install -g @langchain/langgraph-cli
|
||||
WORKDIR /app
|
||||
|
||||
# Next.js build artifacts
|
||||
COPY --from=builder /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=builder /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=builder /app/apps/web/package.json ./apps/web/
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
|
||||
# Agent code + dependencies
|
||||
COPY --from=builder /app/apps/agent ./apps/agent
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Agent code + dependencies
|
||||
COPY agent/ ./agent/
|
||||
RUN cd agent && npm install --ignore-scripts
|
||||
|
||||
# Copy entrypoint
|
||||
COPY entrypoint.sh ./
|
||||
|
||||
@@ -2,86 +2,105 @@
|
||||
|
||||
This is a starter template for building AI agents using [LangGraph](https://www.langchain.com/langgraph) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated LangGraph agent to be built on top of.
|
||||
|
||||
This project is organized as a monorepo using [Turborepo](https://turbo.build) and [pnpm workspaces](https://pnpm.io/workspaces).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js frontend application
|
||||
│ └── agent/ # LangGraph agent
|
||||
├── pnpm-workspace.yaml
|
||||
├── turbo.json
|
||||
├── app/ # Next.js App Router pages and API routes
|
||||
│ ├── page.tsx # Main page
|
||||
│ └── api/copilotkit/ # CopilotKit API route
|
||||
├── agent/ # LangGraph agent
|
||||
│ ├── src/agent.ts # Agent definition
|
||||
│ └── langgraph.json # LangGraph configuration
|
||||
├── scripts/ # Agent run scripts
|
||||
├── public/ # Static assets
|
||||
├── next.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- [pnpm](https://pnpm.io/installation) 9.15.0 or later
|
||||
- Any of the following package managers:
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
- OpenAI API Key (for the LangGraph agent)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install all dependencies (this installs everything for both apps):
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
# Using bun
|
||||
bun install
|
||||
```
|
||||
|
||||
2. Set up your OpenAI API key:
|
||||
2. Set up your environment variables:
|
||||
|
||||
```bash
|
||||
cd apps/agent
|
||||
echo "OPENAI_API_KEY=your-openai-api-key-here" > .env
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Then edit the `.env` file and add your OpenAI API key:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
```
|
||||
|
||||
3. Start the development servers:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
# Using bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will start both the Next.js app (on port 3000) and the LangGraph agent (on port 8123) using Turborepo.
|
||||
This will start both the Next.js app (on port 3000) and the LangGraph agent (on port 8123) concurrently.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
All scripts use Turborepo to run tasks across the monorepo:
|
||||
The following scripts can also be run using your preferred package manager:
|
||||
|
||||
- `pnpm dev` - Starts both the web app and agent servers in development mode
|
||||
- `pnpm dev:studio` - Starts the web app and agent with LangGraph Studio UI
|
||||
- `pnpm build` - Builds all apps for production
|
||||
- `pnpm lint` - Runs linting across all apps
|
||||
|
||||
### Running Scripts for Individual Apps
|
||||
|
||||
You can also run scripts for individual apps using pnpm's filter flag:
|
||||
|
||||
```bash
|
||||
# Run dev for just the web app
|
||||
pnpm --filter web dev
|
||||
|
||||
# Run dev for just the agent
|
||||
pnpm --filter agent dev
|
||||
|
||||
# Or navigate to the app directory
|
||||
cd apps/web
|
||||
pnpm dev
|
||||
```
|
||||
- `dev` - Starts both the web app and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the LangGraph agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `lint` - Runs linting
|
||||
|
||||
## Customization
|
||||
|
||||
The main UI component is in `apps/web/src/app/page.tsx`. You can:
|
||||
The main UI component is in `app/page.tsx`. You can:
|
||||
|
||||
- Modify the theme colors and styling
|
||||
- Add new frontend actions
|
||||
- Utilize shared-state
|
||||
- Customize your user-interface for interacting with LangGraph
|
||||
|
||||
The LangGraph agent code is in `apps/agent/src/`.
|
||||
The LangGraph agent code is in `agent/src/`.
|
||||
|
||||
## 📚 Documentation
|
||||
## Documentation
|
||||
|
||||
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Learn more about LangGraph and its features
|
||||
@@ -101,6 +120,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
||||
|
||||
If you see "I'm having trouble connecting to my tools", make sure:
|
||||
|
||||
1. The LangGraph agent is running on port 8000
|
||||
1. The LangGraph agent is running on port 8123
|
||||
2. Your OpenAI API key is set correctly
|
||||
3. Both servers started successfully
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"graphs": {
|
||||
"starterAgent": "./src/agent.ts:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
"env": "../.env"
|
||||
}
|
||||
@@ -10,11 +10,12 @@
|
||||
"dev": "npx @langchain/langgraph-cli dev --port 8123 --no-browser"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/sdk-js": "1.51.4",
|
||||
"@copilotkit/sdk-js": "1.56.2",
|
||||
"@langchain/core": "^1.0.1",
|
||||
"@langchain/langgraph": "1.0.2",
|
||||
"@langchain/langgraph-checkpoint": "1.0.0",
|
||||
"@langchain/openai": "^1.1.3",
|
||||
"langchain": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/react-core": "1.52.1",
|
||||
"@copilotkit/react-ui": "1.52.1",
|
||||
"@copilotkit/runtime": "1.52.1",
|
||||
"next": "16.0.8",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ services:
|
||||
image: ghcr.io/copilotkit/aimock:latest
|
||||
volumes:
|
||||
- ./fixtures:/fixtures:ro
|
||||
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
|
||||
command:
|
||||
["--fixtures", "/fixtures", "--host", "0.0.0.0", "--validate-on-load"]
|
||||
|
||||
agent:
|
||||
build:
|
||||
context: ./apps/agent
|
||||
dockerfile: ../../docker/Dockerfile.agent
|
||||
context: ./agent
|
||||
dockerfile: ../docker/Dockerfile.agent
|
||||
environment:
|
||||
- OPENAI_API_KEY=test-key-for-aimock
|
||||
- OPENAI_BASE_URL=http://aimock:4010/v1
|
||||
@@ -69,7 +70,7 @@ services:
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium --with-deps 2>/dev/null && npx playwright test starter-smoke --reporter=list",
|
||||
"cd /tests && npm install --no-audit --no-fund 2>/dev/null && npx playwright install chromium && npx playwright test starter-smoke --reporter=list",
|
||||
]
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# Dockerfile for the LangGraph JS agent.
|
||||
# Mirrors the user experience: pnpm install + langgraph-cli dev.
|
||||
# Mirrors the user experience: npm install + langgraph-cli dev.
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy agent package files
|
||||
COPY package.json ./
|
||||
COPY langgraph.json ./
|
||||
@@ -13,7 +11,7 @@ COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN npm install
|
||||
|
||||
EXPOSE 8123
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for the LangGraph JS Next.js frontend.
|
||||
# Builds from the monorepo root so pnpm workspaces resolve correctly.
|
||||
# Builds from the project root.
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
@@ -7,40 +7,30 @@ RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy monorepo package files
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/agent/package.json ./apps/agent/
|
||||
COPY turbo.json ./
|
||||
# Copy package file
|
||||
COPY package.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN npm install --ignore-scripts
|
||||
|
||||
# Build stage
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=deps /app/apps/agent/node_modules ./apps/agent/node_modules
|
||||
|
||||
COPY . .
|
||||
|
||||
# Add standalone output + ignoreBuildErrors + turbopack root for monorepo
|
||||
# Add standalone output + ignoreBuildErrors for Docker build
|
||||
RUN node -e "\
|
||||
const fs=require('fs'); const f='apps/web/next.config.ts'; \
|
||||
const fs=require('fs'); const f='next.config.ts'; \
|
||||
let c=fs.readFileSync(f,'utf8'); \
|
||||
if(!c.includes('standalone')){c=c.replace('};',' output: \"standalone\",\n};');} \
|
||||
if(!c.includes('ignoreBuildErrors')){c=c.replace('};',' typescript: { ignoreBuildErrors: true },\n};');} \
|
||||
if(!c.includes('turbopack')){c=c.replace('};',' turbopack: { root: \"../..\" },\n};');} \
|
||||
fs.writeFileSync(f,c);"
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
RUN pnpm --filter web build
|
||||
RUN npx next build
|
||||
|
||||
# Production stage
|
||||
FROM base AS runner
|
||||
@@ -53,12 +43,12 @@ ENV HOSTNAME=0.0.0.0
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone build output
|
||||
COPY --from=builder /app/apps/web/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Start LangGraph agent server on port 8123
|
||||
cd /app/apps/agent
|
||||
cd /app/agent
|
||||
npx @langchain/langgraph-cli dev --port 8123 --no-browser &
|
||||
AGENT_PID=$!
|
||||
cd /app
|
||||
@@ -10,10 +10,9 @@ cd /app
|
||||
sleep 3
|
||||
|
||||
# Start Next.js frontend
|
||||
cd /app/apps/web
|
||||
cd /app
|
||||
PORT=${PORT:-3000} npx next start --port ${PORT:-3000} &
|
||||
NEXT_PID=$!
|
||||
cd /app
|
||||
|
||||
wait -n $AGENT_PID $NEXT_PID
|
||||
EXIT_CODE=$?
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{
|
||||
"match": {},
|
||||
"response": {
|
||||
"content": "You're currently running against aimock (a mock LLM server). This response is a catch-all for requests that don't match any test fixture. To use a real LLM: (1) Add your OPENAI_API_KEY to .env, (2) Remove or unset OPENAI_BASE_URL from your environment so requests go to OpenAI instead of aimock, (3) Restart with `pnpm dev`."
|
||||
"content": "You're currently running against aimock (a mock LLM server). This response is a catch-all for requests that don't match any test fixture. To use a real LLM: (1) Add your OPENAI_API_KEY to .env, (2) Remove or unset OPENAI_BASE_URL from your environment so requests go to OpenAI instead of aimock, (3) Restart with `npm run dev`."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,17 +2,35 @@
|
||||
"name": "langgraph-js-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint"
|
||||
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
|
||||
"dev:debug": "LOG_LEVEL=debug npm run dev",
|
||||
"dev:ui": "next dev",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"install:agent": "cd agent && npm install",
|
||||
"postinstall": "npm run install:agent"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/react-core": "1.56.2",
|
||||
"@copilotkit/react-ui": "1.56.2",
|
||||
"@copilotkit/runtime": "1.56.2",
|
||||
"next": "16.0.8",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@langchain/langgraph-cli": "^1.0.4",
|
||||
"turbo": "^2.3.3"
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/core": "^1.0.1",
|
||||
@@ -20,6 +38,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0..\agent"
|
||||
npx @langchain/langgraph-cli dev --port 8123 --no-browser
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
npx @langchain/langgraph-cli dev --port 8123 --no-browser
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
OPENAI_API_KEY=
|
||||
NEXT_PUBLIC_BFF_URL=http://localhost:4000/api/copilotkit
|
||||
COPILOTKIT_RUNTIME_URL=http://localhost:4000/api/copilotkit
|
||||
|
||||
# CopilotKit license — run `copilotkit license` to get one
|
||||
COPILOTKIT_LICENSE_TOKEN=
|
||||
@@ -9,3 +9,7 @@ INTELLIGENCE_API_URL=http://localhost:4201
|
||||
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
|
||||
INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
|
||||
INTELLIGENCE_ORGANIZATION_ID=casa-de-erlang
|
||||
|
||||
# Optional thread culler tuning
|
||||
THREAD_STALE_HOURS=3
|
||||
THREAD_CULL_BATCH_SIZE=1000
|
||||
|
||||
@@ -43,7 +43,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
@@ -53,9 +52,6 @@ bun.lockb
|
||||
# Git worktrees
|
||||
.worktrees
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Tools
|
||||
.claude
|
||||
.scratch/
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* When COPILOTKIT_LOCAL is set, rewrites CopilotKit and AG-UI dependencies
|
||||
* to link against local monorepo packages instead of pulling from npm.
|
||||
*
|
||||
* Usage:
|
||||
* COPILOTKIT_LOCAL=1 pnpm install # link to local packages
|
||||
* pnpm install # install from npm (default)
|
||||
*
|
||||
* Expects the AG-UI repo to be cloned alongside CopilotKit:
|
||||
* /some/path/CopilotKit/
|
||||
* /some/path/ag-ui/
|
||||
*/
|
||||
|
||||
const COPILOTKIT_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||
const AGUI_ROOT = path.resolve(COPILOTKIT_ROOT, "..", "ag-ui");
|
||||
|
||||
const LOCAL_PACKAGES = {
|
||||
// CopilotKit
|
||||
"@copilotkit/react-core": path.join(COPILOTKIT_ROOT, "packages/react-core"),
|
||||
"@copilotkit/react-ui": path.join(COPILOTKIT_ROOT, "packages/react-ui"),
|
||||
"@copilotkit/runtime": path.join(COPILOTKIT_ROOT, "packages/runtime"),
|
||||
"@copilotkit/shared": path.join(COPILOTKIT_ROOT, "packages/shared"),
|
||||
"@copilotkit/a2ui-renderer": path.join(
|
||||
COPILOTKIT_ROOT,
|
||||
"packages/a2ui-renderer",
|
||||
),
|
||||
// AG-UI
|
||||
"@ag-ui/client": path.join(AGUI_ROOT, "sdks/typescript/packages/client"),
|
||||
"@ag-ui/core": path.join(AGUI_ROOT, "sdks/typescript/packages/core"),
|
||||
"@ag-ui/encoder": path.join(AGUI_ROOT, "sdks/typescript/packages/encoder"),
|
||||
"@ag-ui/proto": path.join(AGUI_ROOT, "sdks/typescript/packages/proto"),
|
||||
"@ag-ui/a2ui-middleware": path.join(AGUI_ROOT, "middlewares/a2ui-middleware"),
|
||||
"@ag-ui/mcp-apps-middleware": path.join(
|
||||
AGUI_ROOT,
|
||||
"middlewares/mcp-apps-middleware",
|
||||
),
|
||||
};
|
||||
|
||||
function readPackage(pkg) {
|
||||
if (process.env.COPILOTKIT_LOCAL) {
|
||||
for (const [name, localPath] of Object.entries(LOCAL_PACKAGES)) {
|
||||
if (pkg.dependencies?.[name]) {
|
||||
pkg.dependencies[name] = `link:${localPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pkg;
|
||||
}
|
||||
|
||||
module.exports = { hooks: { readPackage } };
|
||||
@@ -19,17 +19,18 @@ This uses CopilotKit's **v2 agent state pattern** where state lives in the agent
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a **Turborepo monorepo** with three apps:
|
||||
This is a small **npm workspaces monorepo** with three apps:
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── app/ # Next.js frontend
|
||||
├── app/ # Vite + React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── page.tsx # Main page - wires up all components
|
||||
│ │ │ └── api/copilotkit/ # CopilotKit API route
|
||||
│ │ ├── App.tsx # Main app shell - wires up all components
|
||||
│ │ ├── main.tsx # Vite entrypoint
|
||||
│ │ └── app/
|
||||
│ │ ├── globals.css # Global styles
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── canvas/ # Todo list UI
|
||||
│ │ │ │ ├── index.tsx # Canvas container
|
||||
@@ -46,7 +47,6 @@ apps/
|
||||
│ └── src/
|
||||
│ ├── todos.py # Todo tools and state schema
|
||||
│ └── query.py # Example data query tool
|
||||
└── mcp/ # MCP (Model Context Protocol) integration
|
||||
```
|
||||
|
||||
## Key Pattern: Agent State with CopilotKit v2
|
||||
@@ -222,33 +222,33 @@ export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React 19, TailwindCSS 4
|
||||
- **Frontend**: Vite 7, React 19, TailwindCSS 4
|
||||
- **Agent**: LangGraph (Python), OpenAI GPT-5.2
|
||||
- **CopilotKit**: React hooks for agent integration (v2)
|
||||
- **Monorepo**: Turborepo with pnpm workspaces
|
||||
- **Other**: MCP (Model Context Protocol) integration, Recharts for generative UI examples
|
||||
- **Monorepo**: npm workspaces + concurrently
|
||||
- **Other**: Recharts for generative UI examples
|
||||
|
||||
## Development
|
||||
|
||||
This is a Turborepo monorepo using pnpm workspaces.
|
||||
This is an npm workspaces monorepo.
|
||||
|
||||
```bash
|
||||
# Install dependencies (all apps)
|
||||
pnpm install
|
||||
npm install
|
||||
|
||||
# Start all apps (app, agent, mcp)
|
||||
pnpm dev
|
||||
# Start all apps (app, bff, agent)
|
||||
npm run dev
|
||||
|
||||
# Start individually
|
||||
pnpm dev:app # Next.js frontend on port 3000
|
||||
pnpm dev:agent # LangGraph agent on port 8123
|
||||
pnpm dev:mcp # MCP server
|
||||
npm run dev:app # Vite frontend on port 3000
|
||||
npm run dev:bff # CopilotKit runtime BFF on port 4000
|
||||
npm run dev:agent # LangGraph agent on port 8123
|
||||
|
||||
# Build all apps
|
||||
pnpm build
|
||||
npm run build
|
||||
|
||||
# Lint all apps
|
||||
pnpm lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
# Stage 1: Build Next.js frontend
|
||||
# Stage 1: Install Node dependencies and build the frontend + BFF
|
||||
FROM node:20-slim AS frontend
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/app/package.json ./apps/app/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
COPY apps/agent/package.json ./apps/agent/
|
||||
COPY apps/bff/package.json ./apps/bff/
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY apps/app/ ./apps/app/
|
||||
|
||||
# Docker override: use AG-UI HttpAgent instead of LangGraphAgent
|
||||
# (LangGraphAgent needs Docker-in-Docker which Railway doesn't provide)
|
||||
COPY docker-route-override.ts ./apps/app/src/app/api/copilotkit/route.ts
|
||||
RUN pnpm --filter @repo/app add @ag-ui/client
|
||||
COPY apps/bff/ ./apps/bff/
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
RUN pnpm --filter @repo/app build
|
||||
RUN npm run build --workspace @repo/app
|
||||
RUN npm run build --workspace @repo/bff
|
||||
|
||||
# Stage 2: Production image with Python + Node
|
||||
FROM python:3.12.10-slim AS runner
|
||||
@@ -55,10 +53,11 @@ RUN uv pip install --system "ag-ui-langgraph[fastapi]==0.0.22" && \
|
||||
# serve.py adapts the original agent for Docker (no langgraph-cli needed)
|
||||
COPY serve.py ./
|
||||
|
||||
# Copy Next.js standalone build
|
||||
COPY --from=frontend /app/apps/app/.next/standalone ./
|
||||
COPY --from=frontend /app/apps/app/.next/static ./apps/app/.next/static
|
||||
COPY --from=frontend /app/apps/app/public ./apps/app/public
|
||||
# Copy frontend build and the BFF runtime.
|
||||
COPY --from=frontend /app/node_modules ./node_modules
|
||||
COPY --from=frontend /app/apps/app/dist ./apps/app/dist
|
||||
COPY --from=frontend /app/apps/app/server.mjs ./apps/app/server.mjs
|
||||
COPY --from=frontend /app/apps/bff/dist ./apps/bff/dist
|
||||
|
||||
COPY entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
@@ -8,7 +8,7 @@ This project is a monorepo with three services:
|
||||
|
||||
| Service | Port | Description |
|
||||
| ------------------------- | ---- | ------------------------------------------ |
|
||||
| **Frontend** (`apps/app`) | 3000 | Next.js app with CopilotKit chat UI |
|
||||
| **Frontend** (`apps/app`) | 3000 | Vite + React app with CopilotKit chat UI |
|
||||
| **BFF** (`apps/bff`) | 4000 | Hono server running the CopilotKit runtime |
|
||||
| **Agent** (`apps/agent`) | 8123 | Python LangGraph agent |
|
||||
|
||||
@@ -25,7 +25,7 @@ When threads are enabled, additional infrastructure runs via Docker Compose:
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.8+
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- npm 10+
|
||||
- OpenAI API Key
|
||||
- Docker (for threads/intelligence support)
|
||||
|
||||
@@ -34,7 +34,7 @@ When threads are enabled, additional infrastructure runs via Docker Compose:
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
@@ -53,29 +53,30 @@ copilotkit license -n my-project
|
||||
|
||||
This authenticates you and issues a `COPILOTKIT_LICENSE_TOKEN`. Add it to your `.env`.
|
||||
|
||||
5. **Start intelligence infrastructure** (for threads):
|
||||
|
||||
First, build the local Docker images from the intelligence repo:
|
||||
|
||||
```bash
|
||||
# From the intelligence repo root
|
||||
./scripts/build-local-images.sh
|
||||
```
|
||||
|
||||
Then start the infrastructure:
|
||||
4. **Start intelligence infrastructure** (for threads):
|
||||
|
||||
```bash
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
6. Start all services:
|
||||
This pulls the GHCR images pinned to `0.1.0-rc.7`.
|
||||
|
||||
5. Start all services:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the frontend, BFF, and agent concurrently.
|
||||
|
||||
You can also run each piece directly:
|
||||
|
||||
```bash
|
||||
npm run dev:app
|
||||
npm run dev:bff
|
||||
npm run dev:agent
|
||||
```
|
||||
|
||||
## Removing Threads
|
||||
|
||||
To strip out threads/intelligence and use this as a plain CopilotKit + LangGraph demo:
|
||||
@@ -83,17 +84,16 @@ To strip out threads/intelligence and use this as a plain CopilotKit + LangGraph
|
||||
### Frontend
|
||||
|
||||
- **Delete** `apps/app/src/components/threads-drawer/` (the entire directory)
|
||||
- **Revert `apps/app/src/app/page.tsx`** to remove the `useThreads` hook, `ThreadsDrawer` component, and the layout wrapper. The page should go back to:
|
||||
- **Revert `apps/app/src/App.tsx`** to remove the `ThreadsDrawer` component and the thread-aware layout wrapper. The app should go back to:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { CopilotChat, CopilotKitProvider } from "@copilotkit/react-core/v2";
|
||||
import { ExampleLayout } from "@/components/example-layout";
|
||||
import { ExampleCanvas } from "@/components/example-canvas";
|
||||
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { ThemeProvider } from "@/hooks/use-theme";
|
||||
|
||||
export default function HomePage() {
|
||||
function HomePage() {
|
||||
useGenerativeUIExamples();
|
||||
useExampleSuggestions();
|
||||
|
||||
@@ -106,6 +106,16 @@ export default function HomePage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CopilotKitProvider runtimeUrl="/api/copilotkit">
|
||||
<HomePage />
|
||||
</CopilotKitProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### BFF
|
||||
@@ -119,14 +129,14 @@ export default function HomePage() {
|
||||
### Infrastructure
|
||||
|
||||
- **Delete** `docker-compose.yml` and `docker/init-db/`
|
||||
- **Remove** the `INTELLIGENCE_*` variables from `.env` / `.env.example`
|
||||
- **Remove** the `INTELLIGENCE_*` variables from `.env` / `.env.example` if you are no longer using CopilotKit Intelligence
|
||||
|
||||
### Summary of files to touch
|
||||
|
||||
| Action | Path |
|
||||
| ------ | ----------------------------------------- |
|
||||
| Delete | `apps/app/src/components/threads-drawer/` |
|
||||
| Edit | `apps/app/src/app/page.tsx` |
|
||||
| Edit | `apps/app/src/App.tsx` |
|
||||
| Edit | `apps/bff/src/server.ts` |
|
||||
| Delete | `docker-compose.yml` |
|
||||
| Delete | `docker/init-db/` |
|
||||
|
||||
@@ -10,26 +10,17 @@ from langchain.agents import create_agent
|
||||
from src.query import query_data
|
||||
from src.todos import AgentState, todo_tools
|
||||
|
||||
# A2UI tools
|
||||
from src.a2ui_dynamic_schema import generate_a2ui
|
||||
from src.a2ui_fixed_schema import search_flights
|
||||
|
||||
agent = create_agent(
|
||||
model="openai:gpt-4.1",
|
||||
tools=[query_data, *todo_tools, generate_a2ui, search_flights],
|
||||
tools=[query_data, *todo_tools],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt="""
|
||||
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
|
||||
|
||||
Tool guidance:
|
||||
- Flights: call search_flights to show flight cards with a pre-built schema.
|
||||
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
|
||||
charts, tables, and cards. It handles rendering automatically.
|
||||
- Charts: call query_data first, then render with the chart component.
|
||||
- Todos: enable app mode first, then manage todos.
|
||||
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
|
||||
respond with a brief confirmation. The UI already updated on the frontend.
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@ def query_data(query: str):
|
||||
Query the database, takes natural language. Always call before showing a chart or graph.
|
||||
"""
|
||||
import time
|
||||
print(f"[A2UI-DEBUG] query_data called: query='{query[:60]}' at {time.strftime('%H:%M:%S')}")
|
||||
print(f"[QUERY-DEBUG] query_data called: query='{query[:60]}' at {time.strftime('%H:%M:%S')}")
|
||||
return _cached_data
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/copilotkit-logo-mark.svg" />
|
||||
<title>CopilotKit + LangGraph</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,24 +3,22 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/a2ui-renderer": "1.55.0",
|
||||
"@copilotkit/react-core": "1.55.0",
|
||||
"@copilotkit/react-ui": "1.55.0",
|
||||
"@copilotkit/runtime": "1.55.0",
|
||||
"@copilotkit/shared": "1.55.0",
|
||||
"@ag-ui/client": "0.0.52",
|
||||
"@copilotkit/react-core": "1.56.2",
|
||||
"@copilotkit/react-ui": "1.56.2",
|
||||
"@hono/node-server": "^1.19.14",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.12.10",
|
||||
"hono": "^4.9.8",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-rnd": "^10.5.2",
|
||||
@@ -34,7 +32,9 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distRoot = path.join("apps", "app", "dist");
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const runtimeUrl =
|
||||
process.env.COPILOTKIT_RUNTIME_URL ?? "http://localhost:4000/api/copilotkit";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const proxyHandler = async (c) => {
|
||||
const requestUrl = new URL(c.req.url);
|
||||
const upstreamBase = new URL(
|
||||
runtimeUrl.endsWith("/") ? runtimeUrl : `${runtimeUrl}/`,
|
||||
);
|
||||
const upstreamPath = requestUrl.pathname.replace(/^\/api\/copilotkit\/?/, "");
|
||||
const upstreamUrl = new URL(
|
||||
`${upstreamPath}${requestUrl.search}`,
|
||||
upstreamBase,
|
||||
);
|
||||
const request = new Request(upstreamUrl, {
|
||||
method: c.req.raw.method,
|
||||
headers: c.req.raw.headers,
|
||||
body:
|
||||
c.req.raw.method === "GET" || c.req.raw.method === "HEAD"
|
||||
? undefined
|
||||
: c.req.raw.body,
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
return fetch(request);
|
||||
};
|
||||
|
||||
app.all("/api/copilotkit", proxyHandler);
|
||||
app.all("/api/copilotkit/*", proxyHandler);
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
serveStatic({
|
||||
root: distRoot,
|
||||
rewriteRequestPath: (requestPath) =>
|
||||
requestPath.startsWith("/") ? requestPath.slice(1) : requestPath,
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("*", serveStatic({ root: distRoot, path: "./index.html" }));
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
() => {
|
||||
console.log(`[app-server] ready at http://0.0.0.0:${port}`);
|
||||
},
|
||||
);
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CopilotKitProvider } from "@copilotkit/react-core/v2";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { ExampleLayout } from "@/components/example-layout";
|
||||
import { ExampleCanvas } from "@/components/example-canvas";
|
||||
import { ThreadsDrawer } from "@/components/threads-drawer";
|
||||
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ThemeProvider } from "@/hooks/use-theme";
|
||||
import { useExampleSuggestions, useGenerativeUIExamples } from "@/hooks";
|
||||
import styles from "@/components/threads-drawer/threads-drawer.module.css";
|
||||
|
||||
export default function HomePage() {
|
||||
const runtimeUrl = "/api/copilotkit";
|
||||
|
||||
function HomePage() {
|
||||
useGenerativeUIExamples();
|
||||
useExampleSuggestions();
|
||||
|
||||
@@ -38,3 +38,13 @@ export default function HomePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CopilotKitProvider runtimeUrl={runtimeUrl}>
|
||||
<HomePage />
|
||||
</CopilotKitProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||