Merge remote-tracking branch 'origin/main' into probable-fold

This commit is contained in:
pftom 2026-05-29 11:56:28 +08:00
commit abab126ea7
1357 changed files with 153898 additions and 43887 deletions

View file

@ -64,13 +64,15 @@ body:
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
label: Logs / screenshots (optional) label: Logs (optional)
description: | description: Paste any error output or console logs. For daemon logs, run `pnpm tools-dev logs --json`.
If relevant, paste any error output, console logs, or screenshots.
For daemon logs, run `pnpm tools-dev logs --json`.
render: shell render: shell
validations:
required: false - type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: Drag and drop or paste images here to show the visual bug.
- type: textarea - type: textarea
id: context id: context

10
.github/actionlint.yaml vendored Normal file
View file

@ -0,0 +1,10 @@
paths:
.github/workflows/*.lock.yml:
ignore:
- 'shellcheck reported issue in this script: SC2016:.+'
- 'shellcheck reported issue in this script: SC2086:.+'
self-hosted-runner:
labels:
- agent-pr-explore
- nexu-win
- release-beta

View file

@ -0,0 +1,34 @@
name: Setup Playwright
description: Restore Playwright browser cache and install browsers
inputs:
package-json-path:
description: Path to package.json containing @playwright/test or playwright devDependency
required: true
install-command:
description: Command used to install browsers
required: true
runs:
using: composite
steps:
- name: Resolve Playwright version
id: playwright-version
shell: bash
run: |
version=$(node -p "const pkg = require('./${{ inputs.package-json-path }}'); const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; (deps['@playwright/test'] || deps.playwright || '').replace(/[^0-9.]/g,'')")
if [ -z "$version" ]; then
echo "Could not resolve Playwright version from ${{ inputs.package-json-path }}" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browser binaries
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
shell: bash
run: ${{ inputs.install-command }}

View file

@ -0,0 +1,41 @@
name: Setup workspace
description: Restore pnpm cache and install dependencies
inputs:
node-version:
description: Node.js version to install
required: false
default: '24'
pnpm-version:
description: pnpm version to install
required: false
default: '10.33.2'
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: ${{ inputs.pnpm-version }}
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

105
.github/scripts/agent-pr-explore-local.sh vendored Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
.github/scripts/agent-pr-explore-local.sh <pr-number>
Runs the Docker-isolated PR exploration path from a local or self-hosted
machine, without relying on a GitHub Actions workflow being present on main.
Required on the host:
docker, gh, jq, node/npm, expect-cli@0.1.3
Optional environment:
BASE_REPO=nexu-io/open-design
RUNNER_TEMP=/tmp/od-agent-pr-explore-local
OD_EXPECT_TIMEOUT_SECONDS=1200
OD_SANDBOX_CPUS=4
OD_SANDBOX_MEMORY=8g
OD_ALLOW_NPX_EXPECT_CLI=1
OD_TRACE_R2_UPLOAD=1
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_ORIGIN=https://...
USAGE
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
pr_number="${1:-${PR_NUMBER:-}}"
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
usage >&2
exit 2
fi
base_repo="${BASE_REPO:-nexu-io/open-design}"
runner_temp="${RUNNER_TEMP:-/tmp/od-agent-pr-explore-local}"
for command_name in docker gh jq node; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "::error::$command_name is required on the mini/local runner" >&2
exit 1
fi
done
if [ -z "${GH_TOKEN:-}" ]; then
if ! GH_TOKEN="$(gh auth token 2>/dev/null)"; then
echo "::error::GH_TOKEN is not set and gh auth token failed. Run gh auth login or export GH_TOKEN." >&2
exit 1
fi
export GH_TOKEN
fi
if ! command -v expect-cli >/dev/null 2>&1 && [ "${OD_ALLOW_NPX_EXPECT_CLI:-0}" != "1" ]; then
echo "::error::expect-cli is not installed. Install it on the mini with: npm install -g expect-cli@${OD_EXPECT_CLI_VERSION:-0.1.3}" >&2
echo " For a one-off smoke run, set OD_ALLOW_NPX_EXPECT_CLI=1 to use the pinned npx fallback." >&2
exit 1
fi
mkdir -p "$runner_temp"
pr_json="$(gh pr view "$pr_number" --repo "$base_repo" --json state,isDraft,headRefOid,baseRefOid,headRepositoryOwner,headRepository)"
state="$(jq -r '.state' <<<"$pr_json")"
draft="$(jq -r '.isDraft' <<<"$pr_json")"
head_sha="$(jq -r '.headRefOid' <<<"$pr_json")"
base_sha="$(jq -r '.baseRefOid' <<<"$pr_json")"
head_repo="$(jq -r '.headRepositoryOwner.login + "/" + .headRepository.name' <<<"$pr_json")"
if [ "$state" != "OPEN" ]; then
echo "::error::Refusing to explore PR $pr_number because state is $state." >&2
exit 1
fi
if [ "$draft" != "false" ]; then
echo "::error::Refusing to explore draft PR $pr_number." >&2
exit 1
fi
if ! [[ "$head_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Invalid PR SHA metadata for PR $pr_number." >&2
exit 1
fi
echo "Running agent PR exploration locally"
echo " PR: $base_repo#$pr_number"
echo " Head: $head_repo@$head_sha"
echo " Base SHA: $base_sha"
echo " Temp root: $runner_temp"
PR_NUMBER="$pr_number" \
HEAD_SHA="$head_sha" \
HEAD_REPO="$head_repo" \
BASE_REPO="$base_repo" \
BASE_SHA="$base_sha" \
RUNNER_TEMP="$runner_temp" \
GH_TOKEN="$GH_TOKEN" \
.github/scripts/agent-pr-explore-sandbox.sh
echo
echo "Artifacts:"
echo " $runner_temp/agent-pr-explore-sandbox/artifacts"

1510
.github/scripts/agent-pr-explore-sandbox.sh vendored Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,209 @@
#!/usr/bin/env bash
#
# Provision / repair the self-hosted agent-pr-explore runner.
#
# The runner that powers `.github/workflows/agent-pr-explore-sandbox.yml` is a
# self-hosted macOS host. Several pieces of its setup are layered on top of the
# base toolchain and are easy to lose on a rebuild (most importantly the
# codex-acp pin -- see below). This script makes that layer reproducible and
# idempotent: run it on the runner, any time, to bring it back to a working
# state. It never prints or embeds secrets.
#
# Run as the runner user (e.g. `mashu`) on the runner host:
# bash provision-agent-pr-explore-runner.sh
#
# ─────────────────────────────────────────────────────────────────────────────
# MANUAL prerequisites this script does NOT do (one-time, need a human/secret):
#
# A. Base toolchain (user-local, no sudo). Install once into ~/agent-pr-explore-bin
# + ~/.npm-global if missing: docker CLI, colima, lima, node, npm, gh,
# expect-cli. Then start colima (give the VM real resources):
# colima start --runtime docker --cpu 8 --memory 13 --disk 80 \
# --vm-type=vz --mount-type=virtiofs --network-address=false
# (Playwright Chromium for the host user is auto-installed by the sandbox
# script's fallback on first run.)
#
# B. Codex ChatGPT login (interactive OAuth, cannot be scripted):
# codex login # complete ChatGPT auth in a browser
# On a headless box, log in on a workstation and copy ~/.codex/auth.json here.
# This script verifies login status and warns if absent.
#
# C. Register the read-only deploy key (printed by this script) on the repo:
# gh api repos/${BASE_REPO}/keys -X POST -f title='agent-pr-explore runner' \
# -f key="$(cat ~/.ssh/od_agent_deploy.pub)" -F read_only=true
# (Needs repo admin. Required so the host can SSH-fetch PR source — the one
# git transport GFW does not reset.)
#
# D. Register + service-install the GitHub Actions runner (token-based):
# ./config.sh --url https://github.com/${BASE_REPO} --token <RUNNER_TOKEN> \
# --labels self-hosted,agent-pr-explore --name macmini-agent-pr-explore
# then install it as a launchd service so it survives reboot.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
# --- config (override via env) -----------------------------------------------
BASE_REPO="${BASE_REPO:-nexu-io/open-design}"
CODEX_MODEL="${CODEX_MODEL:-gpt-5.4}"
ACP_VERSION="${ACP_VERSION:-0.15.0}"
ACP_ARCH_PKG="${ACP_ARCH_PKG:-@zed-industries/codex-acp-darwin-arm64}" # match the runner arch
NPM_MIRROR="${NPM_MIRROR:-https://registry.npmmirror.com}"
DEPLOY_KEY="${DEPLOY_KEY:-$HOME/.ssh/od_agent_deploy}"
MIRROR_DIR="${OD_SANDBOX_REPO_MIRROR:-$HOME/.cache/agent-pr-explore/open-design.git}"
TOOLS_DIR="$HOME/agent-pr-explore-tools"
export PATH="$TOOLS_DIR/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m⚠\033[0m %s\n' "$*"; }
step() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
# --- 0. sanity: base tools present -------------------------------------------
step "0. base toolchain"
missing=0
for c in node npm docker expect-cli; do
if command -v "$c" >/dev/null 2>&1; then ok "$c: $(command -v "$c")"; else warn "$c MISSING — see manual step A"; missing=1; fi
done
[ "$missing" = 1 ] && warn "install the missing base tools first (manual step A), then re-run."
# --- 1. codex CLI ------------------------------------------------------------
step "1. codex CLI"
if command -v codex >/dev/null 2>&1; then
ok "codex present: $(codex --version 2>&1 | head -1)"
else
warn "installing @openai/codex via mirror…"
npm_config_registry="$NPM_MIRROR" npm install -g @openai/codex >/dev/null 2>&1 \
&& ok "codex installed: $(codex --version 2>&1 | head -1)" || warn "codex install FAILED"
fi
# --- 2. codex model pin (ChatGPT account rejects -codex / gpt-5 models) -------
step "2. codex model pin -> $CODEX_MODEL"
mkdir -p "$HOME/.codex"
cfg="$HOME/.codex/config.toml"
touch "$cfg"
if grep -q "^model *= *\"$CODEX_MODEL\"" "$cfg"; then
ok "config.toml already pins model = \"$CODEX_MODEL\""
elif grep -q '^model *=' "$cfg"; then
# Replace just the model line in place; leave any other settings intact.
tmp="$(mktemp)" && sed "s|^model *=.*|model = \"$CODEX_MODEL\"|" "$cfg" > "$tmp" && mv "$tmp" "$cfg"
ok "updated model -> \"$CODEX_MODEL\" (other config.toml settings preserved)"
else
printf 'model = "%s"\n' "$CODEX_MODEL" >> "$cfg"
ok "appended model = \"$CODEX_MODEL\" to config.toml"
fi
# --- 3. codex login (verify only; interactive — manual step B) ---------------
step "3. codex login (ChatGPT OAuth)"
if codex login status 2>&1 | grep -qi 'logged in'; then
ok "$(codex login status 2>&1 | head -1)"
else
warn "codex NOT logged in — run 'codex login' (manual step B) or copy ~/.codex/auth.json here."
fi
# --- 4. codex-acp pin (CRITICAL: expect-cli bundles 0.10 which is incompatible
# with ChatGPT-account auth; reinstalling expect-cli reverts this). -----
step "4. codex-acp pin -> $ACP_VERSION (the fragile one)"
zed="$(npm root -g 2>/dev/null)/expect-cli/node_modules/@zed-industries"
cur="$(cat "$zed/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
if [ "$cur" = "$ACP_VERSION" ]; then
ok "codex-acp already $ACP_VERSION"
elif [ -d "$zed" ]; then
warn "codex-acp is '$cur' — pinning to $ACP_VERSION"
tmp="$(mktemp -d)"; ( cd "$tmp" && npm_config_registry="$NPM_MIRROR" npm pack \
"@zed-industries/codex-acp@$ACP_VERSION" "$ACP_ARCH_PKG@$ACP_VERSION" >/dev/null 2>&1 )
for pair in "codex-acp:@zed-industries/codex-acp" "$(basename "$ACP_ARCH_PKG"):$ACP_ARCH_PKG"; do
dir="${pair%%:*}"; tgz="$(ls "$tmp"/*"${dir}"-"$ACP_VERSION".tgz 2>/dev/null | head -1)"
[ -z "$tgz" ] && { warn "tarball for $dir not fetched"; continue; }
mkdir -p "$tmp/x_$dir"; tar -xzf "$tgz" -C "$tmp/x_$dir"
rm -rf "$zed/$dir"/* && cp -a "$tmp/x_$dir/package/." "$zed/$dir/"
done
chmod +x "$zed/$(basename "$ACP_ARCH_PKG")/bin/"* 2>/dev/null || true
rm -rf "$tmp"
now="$(cat "$zed/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
[ "$now" = "$ACP_VERSION" ] && ok "codex-acp now $ACP_VERSION" || warn "codex-acp pin FAILED (still $now)"
else
warn "expect-cli not found at $zed — install expect-cli first (manual step A)."
fi
# --- 5. deploy key (generate if missing; registration is manual step C) ------
step "5. SSH deploy key"
if [ -f "$DEPLOY_KEY" ]; then
ok "deploy key present: $DEPLOY_KEY"
else
# On a fresh-rebuild host ~/.ssh often does not exist yet; create it first so
# ssh-keygen doesn't fail with "No such file or directory".
mkdir -p "$(dirname "$DEPLOY_KEY")" && chmod 700 "$(dirname "$DEPLOY_KEY")" 2>/dev/null || true
if ssh-keygen -t ed25519 -N "" -C "agent-pr-explore-deploy@$(hostname)" -f "$DEPLOY_KEY" >/dev/null; then
ok "generated $DEPLOY_KEY"
else
warn "ssh-keygen failed — deploy key NOT created; mirror bootstrap will not work until fixed."
fi
fi
if [ -f "$DEPLOY_KEY.pub" ]; then
warn "ensure this pubkey is a READ-ONLY deploy key on $BASE_REPO (manual step C):"
echo " $(cat "$DEPLOY_KEY.pub")"
fi
# --- 6. base repo git mirror (so per-PR fetches are small deltas) ------------
step "6. git mirror"
export GIT_SSH_COMMAND="ssh -i $DEPLOY_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
if [ -d "$MIRROR_DIR" ] && git --git-dir="$MIRROR_DIR" rev-parse HEAD >/dev/null 2>&1; then
ok "mirror present ($(du -sh "$MIRROR_DIR" 2>/dev/null | cut -f1)); refreshing main…"
git --git-dir="$MIRROR_DIR" fetch --no-tags --depth=1 origin main >/dev/null 2>&1 && ok "main refreshed" || warn "mirror refresh failed (network?)"
else
mkdir -p "$(dirname "$MIRROR_DIR")"
warn "seeding mirror (one-time, ~150MB over SSH)…"
git clone --bare --depth=1 --single-branch --branch main "git@github.com:${BASE_REPO}.git" "$MIRROR_DIR" >/dev/null 2>&1 \
&& ok "mirror seeded" || warn "mirror clone FAILED (deploy key registered? network?)"
fi
mkdir -p "$HOME/.cache/agent-pr-explore/pnpm-store" "$HOME/.cache/agent-pr-explore/reports"
ok "pnpm-store + reports dirs ready"
# --- 7. base image refresh helper + weekly cron ------------------------------
step "7. sandbox image refresh helper + cron"
mkdir -p "$TOOLS_DIR"
cat > "$TOOLS_DIR/refresh-sandbox-image.sh" <<'RSH'
#!/usr/bin/env bash
# Best-effort refresh of the sandbox base image. The sandbox script skips
# `docker pull` when the image is cached (the runner's docker.io access is
# flaky), so this is the decoupled refresh path; it never fails the host.
set -uo pipefail
export PATH="$HOME/agent-pr-explore-tools/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
image="${OD_SANDBOX_IMAGE:-node:24-bookworm}"
ts() { date "+%Y-%m-%dT%H:%M:%S%z"; }
echo "[$(ts)] refresh start: $image"
colima status >/dev/null 2>&1 || { echo "[$(ts)] colima down; skip"; exit 0; }
before="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || echo none)"
if docker pull "$image"; then
after="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || echo none)"
[ "$before" != "$after" ] && { echo "[$(ts)] refreshed $before -> $after"; docker image prune -f >/dev/null 2>&1 || true; } || echo "[$(ts)] up to date"
else
echo "[$(ts)] pull failed (registry unreachable?); keeping cached $before"
fi
echo "[$(ts)] done"
RSH
chmod +x "$TOOLS_DIR/refresh-sandbox-image.sh"
ok "wrote $TOOLS_DIR/refresh-sandbox-image.sh"
cron_line="17 4 * * 0 $TOOLS_DIR/refresh-sandbox-image.sh >> $TOOLS_DIR/image-refresh.log 2>&1"
if crontab -l 2>/dev/null | grep -qF "refresh-sandbox-image.sh"; then
ok "weekly refresh cron already installed"
else
{ crontab -l 2>/dev/null; echo "# agent-pr-explore weekly base-image refresh"; echo "$cron_line"; } | crontab - && ok "installed weekly refresh cron"
fi
# --- 8. readiness self-check helper ------------------------------------------
step "8. readiness self-check helper"
cat > "$HOME/check-agent-ready.sh" <<'CHK'
#!/usr/bin/env bash
# Quick readiness check: VPN reaches chatgpt backend + Codex responds.
export PATH="$HOME/agent-pr-explore-tools/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
ok=1
echo "1. chatgpt backend: $(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://chatgpt.com/backend-api/ 2>/dev/null || echo FAIL) (403/200 = reachable)"
echo "2. codex model: $(grep '^model' "$HOME/.codex/config.toml" 2>/dev/null)"
echo "3. codex-acp: $(cat "$(npm root -g)/expect-cli/node_modules/@zed-industries/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
out="$(perl -e 'alarm shift; exec @ARGV' 90 codex exec --skip-git-repo-check 'reply with exactly READY_OK' 2>&1)"
if printf '%s' "$out" | grep -q READY_OK; then echo "4. codex: ✅ responds"; else echo "4. codex: ❌ no response"; ok=0; fi
[ "$ok" = 1 ] && echo "==> READY ✅" || echo "==> NOT READY ❌"
CHK
chmod +x "$HOME/check-agent-ready.sh"
ok "wrote ~/check-agent-ready.sh"
step "done — run ~/check-agent-ready.sh after VPN/login to confirm"

View file

@ -8,21 +8,37 @@ foreach ($name in @("CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN", "RELEASE_CHANNEL", "
$assetSuffix = if ($null -eq $env:WINDOWS_ASSET_SUFFIX) { "" } else { $env:WINDOWS_ASSET_SUFFIX } $assetSuffix = if ($null -eq $env:WINDOWS_ASSET_SUFFIX) { "" } else { $env:WINDOWS_ASSET_SUFFIX }
$versionPathSuffix = if ($null -eq $env:ASSET_VERSION_SUFFIX) { "" } else { $env:ASSET_VERSION_SUFFIX } $versionPathSuffix = if ($null -eq $env:ASSET_VERSION_SUFFIX) { "" } else { $env:ASSET_VERSION_SUFFIX }
$includeZip = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_INCLUDE_ZIP)) { $true } else { $env:WINDOWS_INCLUDE_ZIP -ne "false" }
$releaseDir = Join-Path $env:RUNNER_TEMP "release-assets" $releaseDir = Join-Path $env:RUNNER_TEMP "release-assets"
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder/Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe" $builderDir = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder"
$sourceInstaller = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe"
$sourceZip = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-portable.zip"
if (!(Test-Path $sourceInstaller)) { if (!(Test-Path $sourceInstaller)) {
throw "expected installer not found at $sourceInstaller" throw "expected installer not found at $sourceInstaller"
} }
if ($includeZip -and !(Test-Path $sourceZip)) {
throw "expected portable zip not found at $sourceZip (build with --to all or --to zip, or set WINDOWS_INCLUDE_ZIP=false to skip)"
}
$versionedInstaller = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-setup.exe" $versionedInstaller = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-setup.exe"
$checksumFile = "$versionedInstaller.sha256" $versionedZip = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-portable.zip"
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller) $installerChecksumFile = "$versionedInstaller.sha256"
$zipChecksumFile = "$versionedZip.sha256"
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
$installerPath = Join-Path $releaseDir $versionedInstaller $installerPath = Join-Path $releaseDir $versionedInstaller
$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() $installerHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $checksumFile) "$installerHash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $installerChecksumFile)
if ($includeZip) {
Copy-Item $sourceZip (Join-Path $releaseDir $versionedZip)
$zipPath = Join-Path $releaseDir $versionedZip
$zipHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
"$zipHash $versionedZip" | Set-Content -Path (Join-Path $releaseDir $zipChecksumFile)
}
$installerBytes = [System.IO.File]::ReadAllBytes($installerPath) $installerBytes = [System.IO.File]::ReadAllBytes($installerPath)
$installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes)) $installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes))
$installerSize = (Get-Item $installerPath).Length $installerSize = (Get-Item $installerPath).Length
@ -39,6 +55,9 @@ $releaseNotes = if ([string]::IsNullOrWhiteSpace($env:RELEASE_NOTES)) {
} else { } else {
$env:RELEASE_NOTES $env:RELEASE_NOTES
} }
# latest.yml is the electron-updater auto-update feed; it only references the
# NSIS installer because the portable zip is a manual-download convenience and
# is not consumed by the in-app updater.
@( @(
"version: `"${env:RELEASE_VERSION}`"" "version: `"${env:RELEASE_VERSION}`""
'files:' 'files:'

View file

@ -15,6 +15,8 @@ fi
artifacts_file="$(mktemp)" artifacts_file="$(mktemp)"
trap 'rm -f "$artifacts_file"' EXIT trap 'rm -f "$artifacts_file"' EXIT
artifact_name_regex="${ARTIFACT_NAME_REGEX:-}"
cleanup_description="${ARTIFACT_CLEANUP_DESCRIPTION:-intermediate Actions artifacts after publish}"
gh api --paginate \ gh api --paginate \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
@ -32,6 +34,9 @@ while IFS=$'\t' read -r artifact_id artifact_name; do
if [ -z "$artifact_id" ]; then if [ -z "$artifact_id" ]; then
continue continue
fi fi
if [ -n "$artifact_name_regex" ] && ! [[ "$artifact_name" =~ $artifact_name_regex ]]; then
continue
fi
echo "Deleting workflow artifact $artifact_name ($artifact_id)" echo "Deleting workflow artifact $artifact_name ($artifact_id)"
gh api \ gh api \
@ -41,6 +46,11 @@ while IFS=$'\t' read -r artifact_id artifact_name; do
deleted_count=$((deleted_count + 1)) deleted_count=$((deleted_count + 1))
done < "$artifacts_file" done < "$artifacts_file"
if [ "$deleted_count" -eq 0 ] && [ -n "$artifact_name_regex" ]; then
echo "No workflow artifacts matched ARTIFACT_NAME_REGEX=$artifact_name_regex for run $GITHUB_RUN_ID"
exit 0
fi
echo "Deleted $deleted_count workflow artifacts from run $GITHUB_RUN_ID" echo "Deleted $deleted_count workflow artifacts from run $GITHUB_RUN_ID"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
@ -48,6 +58,6 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "" echo ""
echo "### Workflow artifacts" echo "### Workflow artifacts"
echo "" echo ""
echo "Deleted $deleted_count intermediate Actions artifacts after publish." echo "Deleted $deleted_count $cleanup_description."
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
fi fi

View file

@ -175,10 +175,18 @@ if (platform === "mac") {
} else if (platform === "win") { } else if (platform === "win") {
const suffix = optional("WIN_ASSET_SUFFIX", assetVersionSuffix); const suffix = optional("WIN_ASSET_SUFFIX", assetVersionSuffix);
const installer = `open-design-${releaseVersion}${suffix}-win-x64-setup.exe`; const installer = `open-design-${releaseVersion}${suffix}-win-x64-setup.exe`;
const portableZip = `open-design-${releaseVersion}${suffix}-win-x64-portable.zip`;
const includeZip = optional("WIN_INCLUDE_ZIP", "true") !== "false";
const artifacts = { installer: fileEntry(installer, contentType(installer)) };
const assetNames = [installer, `${installer}.sha256`, "latest.yml"];
if (includeZip) {
artifacts.portableZip = fileEntry(portableZip, contentType(portableZip));
assetNames.push(portableZip, `${portableZip}.sha256`);
}
config = { config = {
arch: "x64", arch: "x64",
artifacts: { installer: fileEntry(installer, contentType(installer)) }, artifacts,
assetNames: [installer, `${installer}.sha256`, "latest.yml"], assetNames,
feed: { feed: {
latestUrl: publicUrl(latestPrefix, "latest.yml"), latestUrl: publicUrl(latestPrefix, "latest.yml"),
name: "latest.yml", name: "latest.yml",

View file

@ -109,8 +109,17 @@ mac_zip="open-design-$RELEASE_VERSION$asset_version_suffix-mac-arm64.zip"
mac_intel_dmg="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.dmg" mac_intel_dmg="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.dmg"
mac_intel_zip="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.zip" mac_intel_zip="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.zip"
win_installer="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-setup.exe" win_installer="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-setup.exe"
win_portable_zip="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-portable.zip"
linux_appimage="open-design-$RELEASE_VERSION$linux_asset_suffix-linux-x64.AppImage" linux_appimage="open-design-$RELEASE_VERSION$linux_asset_suffix-linux-x64.AppImage"
metadata_path="$release_root/metadata.json" metadata_path="$release_root/metadata.json"
win_include_zip="${WIN_INCLUDE_ZIP:-true}"
case "$win_include_zip" in
true | false) ;;
*)
echo "unsupported WIN_INCLUDE_ZIP: $win_include_zip" >&2
exit 1
;;
esac
if [ "$ENABLE_MAC" = "true" ]; then if [ "$ENABLE_MAC" = "true" ]; then
upload "$release_root/mac/$mac_dmg" "$version_prefix/$mac_dmg" "application/x-apple-diskimage" "public, max-age=31536000, immutable" upload "$release_root/mac/$mac_dmg" "$version_prefix/$mac_dmg" "application/x-apple-diskimage" "public, max-age=31536000, immutable"
@ -139,6 +148,13 @@ if [ "$ENABLE_WIN" = "true" ]; then
echo "win_installer_url=$public_origin/$version_prefix/$win_installer" echo "win_installer_url=$public_origin/$version_prefix/$win_installer"
echo "win_feed_url=$public_origin/$latest_prefix/latest.yml" echo "win_feed_url=$public_origin/$latest_prefix/latest.yml"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
if [ "$win_include_zip" = "true" ]; then
upload "$release_root/win/$win_portable_zip" "$version_prefix/$win_portable_zip" "application/zip" "public, max-age=31536000, immutable"
upload "$release_root/win/$win_portable_zip.sha256" "$version_prefix/$win_portable_zip.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable"
{
echo "win_portable_zip_url=$public_origin/$version_prefix/$win_portable_zip"
} >> "$GITHUB_OUTPUT"
fi
fi fi
if [ "$ENABLE_MAC_INTEL" = "true" ]; then if [ "$ENABLE_MAC_INTEL" = "true" ]; then
@ -177,6 +193,8 @@ MAC_ZIP="$mac_zip" \
MAC_INTEL_DMG="$mac_intel_dmg" \ MAC_INTEL_DMG="$mac_intel_dmg" \
MAC_INTEL_ZIP="$mac_intel_zip" \ MAC_INTEL_ZIP="$mac_intel_zip" \
WIN_INSTALLER="$win_installer" \ WIN_INSTALLER="$win_installer" \
WIN_PORTABLE_ZIP="$win_portable_zip" \
WIN_INCLUDE_ZIP="$win_include_zip" \
LINUX_APPIMAGE="$linux_appimage" \ LINUX_APPIMAGE="$linux_appimage" \
MAC_ARTIFACT_MODE="$mac_artifact_mode" \ MAC_ARTIFACT_MODE="$mac_artifact_mode" \
METADATA_PATH="$metadata_path" \ METADATA_PATH="$metadata_path" \
@ -236,6 +254,12 @@ if (enabled("ENABLE_MAC")) {
}; };
} }
if (enabled("ENABLE_WIN")) { if (enabled("ENABLE_WIN")) {
const winArtifacts = {
installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"),
};
if (env.WIN_INCLUDE_ZIP !== "false") {
winArtifacts.portableZip = fileEntry("win", env.WIN_PORTABLE_ZIP, "application/zip");
}
platforms.win = { platforms.win = {
arch: "x64", arch: "x64",
enabled: true, enabled: true,
@ -245,9 +269,7 @@ if (enabled("ENABLE_WIN")) {
url: url(versionPrefix, "latest.yml"), url: url(versionPrefix, "latest.yml"),
}, },
signed: false, signed: false,
artifacts: { artifacts: winArtifacts,
installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"),
},
}; };
} }
if (enabled("ENABLE_LINUX")) { if (enabled("ENABLE_LINUX")) {

View file

@ -65,6 +65,7 @@ if (platforms.mac.enabled) {
if (platforms.win.enabled) { if (platforms.win.enabled) {
platforms.win.artifacts = { platforms.win.artifacts = {
installer: optional("R2_WIN_INSTALLER_URL"), installer: optional("R2_WIN_INSTALLER_URL"),
portableZip: optional("R2_WIN_PORTABLE_ZIP_URL"),
}; };
platforms.win.feed = optional("R2_WIN_FEED_URL"); platforms.win.feed = optional("R2_WIN_FEED_URL");
platforms.win.e2e = platformReport("win"); platforms.win.e2e = platformReport("win");
@ -142,7 +143,10 @@ const platformRows = [
[ [
"Windows x64", "Windows x64",
platformStatus(platforms.win, "Published"), platformStatus(platforms.win, "Published"),
linkList([{ label: "Installer", url: platforms.win.artifacts?.installer }]), linkList([
{ label: "Installer", url: platforms.win.artifacts?.installer },
{ label: "Portable ZIP", url: platforms.win.artifacts?.portableZip },
]),
link("latest.yml", platforms.win.feed), link("latest.yml", platforms.win.feed),
], ],
[ [

View file

@ -134,6 +134,9 @@ if [ "$ENABLE_WIN" = "true" ]; then
grep -F "version: \"$RELEASE_VERSION\"" "$downloaded_feed" grep -F "version: \"$RELEASE_VERSION\"" "$downloaded_feed"
grep -F "$R2_WIN_INSTALLER_URL" "$downloaded_feed" grep -F "$R2_WIN_INSTALLER_URL" "$downloaded_feed"
curl -fsSI "$R2_WIN_INSTALLER_URL" >/dev/null curl -fsSI "$R2_WIN_INSTALLER_URL" >/dev/null
if [ -n "${R2_WIN_PORTABLE_ZIP_URL:-}" ]; then
curl -fsSI "$R2_WIN_PORTABLE_ZIP_URL" >/dev/null
fi
require_report_file "win/manifest.json" require_report_file "win/manifest.json"
require_report_file "win/screenshots/open-design-win-smoke.png" require_report_file "win/screenshots/open-design-win-smoke.png"
require_report_file "win/suite-result.json" require_report_file "win/suite-result.json"

28
.github/workflows/actionlint.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: actionlint
on:
pull_request:
paths:
- .github/workflows/**
push:
branches:
- main
paths:
- .github/workflows/**
permissions:
contents: read
jobs:
actionlint:
name: Lint GitHub Actions workflows
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Check workflow files
uses: docker://rhysd/actionlint:1.7.12
with:
args: -color

View file

@ -0,0 +1,330 @@
name: agent-pr-explore-sandbox
# Trusted-orchestrator workflow for PR exploration on the self-hosted runner.
# Triggered by manual workflow_dispatch, or by a maintainer commenting
# "/explore" on a PR (write-access gated). It deliberately does not auto-run on
# every PR, so ordinary PRs do not accumulate waiting checks; PR code only ever
# runs inside the Docker sandbox without secrets.
on:
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to explore.
required: true
type: string
skip_comment:
description: Skip posting the exploration report comment on the PR (the report is still uploaded as an artifact). Use for validation/dry runs.
required: false
default: false
type: boolean
# A maintainer commenting "/explore" on a PR explores that PR. issue_comment
# runs in the base (trusted) context with secrets, so the job `if` gate below
# restricts it to users with write access; untrusted PR code still runs only
# inside the Docker sandbox without secrets.
issue_comment:
types: [created]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: agent-pr-explore-sandbox-${{ github.event.issue.number || inputs.pr_number }}
cancel-in-progress: true
jobs:
sandbox:
name: Sandbox PR runtime
runs-on: [self-hosted, agent-pr-explore]
# No environment approval gate: both triggers are already write-access
# gated (workflow_dispatch needs repo write; the "/explore" comment path is
# gated on author_association below), and PR code runs only in the sandbox.
# R2 credentials are repo-level secrets, so they remain available here.
timeout-minutes: 45
# Manual dispatch is always allowed. A "/explore" PR comment is allowed only
# from a user with write access (OWNER/MEMBER/COLLABORATOR), never on issues.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/explore') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
steps:
- name: Acknowledge /explore command
if: ${{ github.event_name == 'issue_comment' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMENT_ID: ${{ github.event.comment.id }}
PR_NUMBER: ${{ github.event.issue.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -uo pipefail
# 👀 on the command comment = picked up.
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=eyes --silent || true
# Post a placeholder carrying the report marker; the report step later
# PATCHes this same comment, so the run produces one evolving comment.
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
body="🔍 Exploring this PR in an isolated sandbox — [follow the run]($RUN_URL). The report will replace this comment when it is ready.
$marker"
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
if [ -n "$existing" ]; then
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
else
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
fi
- name: Fetch trusted base script
# Only the self-contained sandbox script is needed on the trusted host;
# PR code is checked out inside Docker. A full actions/checkout of this
# large repo stalled on the self-hosted runner before the agent ever
# ran, so fetch just the one trusted file via the API instead. The ref
# is pinned to the trusted base/dispatch commit, never PR head.
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TRUSTED_REF: ${{ github.event.pull_request.base.sha || github.sha }}
run: |
set -euo pipefail
mkdir -p .github/scripts
gh api \
-H 'Accept: application/vnd.github.raw' \
"repos/$GITHUB_REPOSITORY/contents/.github/scripts/agent-pr-explore-sandbox.sh?ref=$TRUSTED_REF" \
> .github/scripts/agent-pr-explore-sandbox.sh
chmod +x .github/scripts/agent-pr-explore-sandbox.sh
echo "Fetched trusted agent-pr-explore-sandbox.sh at $TRUSTED_REF"
- name: Resolve PR metadata
id: pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number }}
run: |
set -euo pipefail
if ! [[ "$EVENT_PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid PR number: $EVENT_PR_NUMBER"
exit 1
fi
state="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state --jq '.state')"
draft="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')"
author="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')"
head_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq '.headRefOid')"
head_repo="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRepositoryOwner,headRepository --jq '.headRepositoryOwner.login + "/" + .headRepository.name')"
base_repo="$GITHUB_REPOSITORY"
base_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json baseRefOid --jq '.baseRefOid')"
if [ "$state" != "OPEN" ]; then
echo "::error::Refusing to explore PR $EVENT_PR_NUMBER because state is $state."
exit 1
fi
if [ "$draft" != "false" ]; then
echo "::error::Refusing to explore draft PR $EVENT_PR_NUMBER."
exit 1
fi
if [ "$base_repo" != "$GITHUB_REPOSITORY" ]; then
echo "::error::Unexpected base repo $base_repo."
exit 1
fi
if ! [[ "$head_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Invalid PR SHA metadata."
exit 1
fi
{
echo "number=$EVENT_PR_NUMBER"
echo "author=$author"
echo "head_sha=$head_sha"
echo "head_repo=$head_repo"
echo "base_sha=$base_sha"
} >> "$GITHUB_OUTPUT"
- name: Run expect against Docker-isolated PR app
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
BASE_REPO: ${{ github.repository }}
BASE_SHA: ${{ steps.pr.outputs.base_sha }}
OD_SANDBOX_CPUS: "4"
OD_SANDBOX_MEMORY: "8g"
OD_SANDBOX_READY_TIMEOUT_SECONDS: "900"
OD_EXPECT_TIMEOUT_SECONDS: "1200"
OD_EXPECT_CONTEXT_MAX_BYTES: "120000"
OD_TRACE_R2_UPLOAD: "1"
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID || secrets.CLOUDFLARE_R2_RELEASES_AK }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY || secrets.CLOUDFLARE_R2_RELEASES_SK }}
R2_BUCKET: ${{ secrets.R2_BUCKET || secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN || secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT || secrets.CLOUDFLARE_R2_RELEASES_URL }}
run: .github/scripts/agent-pr-explore-sandbox.sh
- name: Upload sandbox artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: agent-pr-explore-sandbox-${{ steps.pr.outputs.number }}-${{ steps.pr.outputs.head_sha }}
# The trace zips (~28MB) and videos are already published to R2; keep
# them out of the GitHub artifact so it stays small (report + logs).
path: |
${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.zip
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.webm
if-no-files-found: warn
retention-days: 7
- name: Comment exploration report
if: ${{ always() && !inputs.skip_comment }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
PR_AUTHOR: ${{ steps.pr.outputs.author }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
report="$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md"
if [ ! -s "$report" ]; then
echo "No agent exploration report was produced; skipping PR comment."
exit 0
fi
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
body_file="$(mktemp)"
{
cat "$report"
echo
echo "$marker"
} > "$body_file"
comment_id="$(
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" \
| tail -n 1
)"
body="$(cat "$body_file")"
if [ -n "$comment_id" ]; then
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" -X PATCH -f body="$body" --silent
else
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent
fi
pr_author_lc="$(printf '%s' "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')"
case "$pr_author_lc" in
nettee|mrcfps|alchemistklk|siri-ray)
;;
*)
echo "PR author $PR_AUTHOR is not configured for inline agent reports; skipping inline review comment."
exit 0
;;
esac
files_json="$(mktemp)"
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$files_json"
inline_target="$(
FILES_JSON="$files_json" node <<'NODE'
const fs = require("node:fs");
const pages = JSON.parse(fs.readFileSync(process.env.FILES_JSON, "utf8"));
const files = pages.flat();
function firstAddedLine(file) {
if (typeof file.patch !== "string") return null;
let newLine = null;
for (const line of file.patch.split("\n")) {
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
if (hunk) {
newLine = Number(hunk[1]);
continue;
}
if (newLine == null) continue;
if (line.startsWith("+") && !line.startsWith("+++")) return newLine;
if (!line.startsWith("-")) newLine += 1;
}
return null;
}
for (const file of files) {
if (!file || file.status === "removed") continue;
const line = firstAddedLine(file);
if (line != null) {
process.stdout.write(JSON.stringify({ path: file.filename, line }));
process.exit(0);
}
}
NODE
)"
if [ -z "$inline_target" ]; then
echo "No added diff line was available for an inline agent report; skipping inline review comment."
exit 0
fi
inline_path="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(target.path)' "$inline_target")"
inline_line="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(String(target.line))' "$inline_target")"
inline_marker="<!-- agent-pr-explore-inline:${PR_NUMBER} -->"
inline_body_file="$(mktemp)"
{
cat "$report"
echo
echo "$inline_marker"
} > "$inline_body_file"
inline_body="$(cat "$inline_body_file")"
inline_comment_id="$(
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$inline_marker\"))) | .id" \
| tail -n 1
)"
if [ -n "$inline_comment_id" ]; then
gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$inline_comment_id" -X PATCH -f body="$inline_body" --silent
else
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" \
-f body="$inline_body" \
-f commit_id="$HEAD_SHA" \
-f path="$inline_path" \
-F line="$inline_line" \
-f side=RIGHT \
--silent
fi
- name: React done on /explore command
if: ${{ success() && github.event_name == 'issue_comment' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMENT_ID: ${{ github.event.comment.id }}
run: gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=rocket --silent || true
- name: Report /explore command failure
if: ${{ failure() && github.event_name == 'issue_comment' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMENT_ID: ${{ github.event.comment.id }}
PR_NUMBER: ${{ github.event.issue.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -uo pipefail
# If a report was produced before the non-zero exit, the report step
# already posted it — leave it rather than clobbering useful output.
if [ -s "$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md" ]; then
echo "A report exists despite the non-zero exit; leaving it in place."
exit 0
fi
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content="-1" --silent || true
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
body="⚠️ Exploration run failed before producing a report — see [the run]($RUN_URL).
$marker"
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
if [ -n "$existing" ]; then
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
else
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
fi

View file

@ -0,0 +1,36 @@
# Placeholder for PR Explore Agent.
#
# GitHub only reliably triggers pull_request workflows once a workflow
# file with the same path exists on the default branch. The real
# generated gh-aw workflow is introduced by PR #2604; this placeholder
# is intentionally inert and can be replaced by the compiled workflow
# when that PR merges.
name: "PR Explore Agent - placeholder"
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "apps/web/**"
- "apps/landing-page/**"
- "design-templates/open-design-landing/**"
- "skills/**"
- "design-systems/**"
- "craft/**"
- "templates/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/agent-pr-explore.lock.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
placeholder:
name: Placeholder
if: ${{ github.event_name == 'agent_pr_explore_placeholder_never' }}
runs-on: ubuntu-latest
steps:
- run: echo "agent-pr-explore placeholder only"

View file

@ -87,9 +87,11 @@ jobs:
if [ -n "$FEISHU_BLOG_DIGEST_WEBHOOK" ]; then if [ -n "$FEISHU_BLOG_DIGEST_WEBHOOK" ]; then
feishu=true feishu=true
fi fi
echo "gsc=$gsc" >> "$GITHUB_OUTPUT" {
echo "bot=$bot" >> "$GITHUB_OUTPUT" echo "gsc=$gsc"
echo "feishu=$feishu" >> "$GITHUB_OUTPUT" echo "bot=$bot"
echo "feishu=$feishu"
} >> "$GITHUB_OUTPUT"
{ {
echo "### Blog 3-day report configuration" echo "### Blog 3-day report configuration"
echo "- GSC auth configured: \`$gsc\`" echo "- GSC auth configured: \`$gsc\`"
@ -122,16 +124,16 @@ jobs:
GSC_SERVICE_ACCOUNT_KEY: ${{ secrets.GSC_SERVICE_ACCOUNT_KEY }} GSC_SERVICE_ACCOUNT_KEY: ${{ secrets.GSC_SERVICE_ACCOUNT_KEY }}
run: | run: |
mkdir -p .blog-indexing mkdir -p .blog-indexing
flags="" flags=()
if [ -n "${{ github.event.inputs.today }}" ]; then if [ -n "${{ github.event.inputs.today }}" ]; then
flags="$flags --today ${{ github.event.inputs.today }}" flags+=(--today "${{ github.event.inputs.today }}")
fi fi
if [ "${{ github.event.inputs.skip_inspect }}" = "true" ]; then if [ "${{ github.event.inputs.skip_inspect }}" = "true" ]; then
flags="$flags --no-inspect" flags+=(--no-inspect)
fi fi
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/report-3day.ts \ pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/report-3day.ts \
--summary-out ../../.blog-indexing/blog-traffic-digest-summary.json \ --summary-out ../../.blog-indexing/blog-traffic-digest-summary.json \
$flags "${flags[@]}"
# Surface the latest section in the run summary for quick review. # Surface the latest section in the run summary for quick review.
{ {
echo "### Latest digest section" echo "### Latest digest section"

View file

@ -251,7 +251,9 @@ jobs:
run: | run: |
existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog indexing — URLs stalled in Search Console"' --json number --jq '.[0].number // empty') existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog indexing — URLs stalled in Search Console"' --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then if [ -n "$existing" ]; then
gh issue comment "$existing" --repo nexu-io/open-design --body 'All previously stalled URLs have reached `indexed` status. Closing automatically. — `blog-indexing-monitor`' body_file="$RUNNER_TEMP/blog-indexing-monitor-stall-close.md"
printf '%s\n' "All previously stalled URLs have reached \`indexed\` status. Closing automatically. — \`blog-indexing-monitor\`" > "$body_file"
gh issue comment "$existing" --repo nexu-io/open-design --body-file "$body_file"
gh issue close "$existing" --repo nexu-io/open-design gh issue close "$existing" --repo nexu-io/open-design
fi fi
@ -262,7 +264,9 @@ jobs:
run: | run: |
existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog traffic — indexed posts with zero impressions"' --json number --jq '.[0].number // empty') existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog traffic — indexed posts with zero impressions"' --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then if [ -n "$existing" ]; then
gh issue comment "$existing" --repo nexu-io/open-design --body 'All previously low-traffic tracked URLs now have impressions or no longer match the escalation window. Closing automatically. — `blog-indexing-monitor`' body_file="$RUNNER_TEMP/blog-indexing-monitor-low-traffic-close.md"
printf '%s\n' "All previously low-traffic tracked URLs now have impressions or no longer match the escalation window. Closing automatically. — \`blog-indexing-monitor\`" > "$body_file"
gh issue comment "$existing" --repo nexu-io/open-design --body-file "$body_file"
gh issue close "$existing" --repo nexu-io/open-design gh issue close "$existing" --repo nexu-io/open-design
fi fi

View file

@ -1,6 +1,8 @@
name: blog-indexing-on-deploy name: blog-indexing-on-deploy
# Runs after every successful `landing-page-deploy` on main. The job is # Runs after every successful `landing-page-production` promotion. Staging
# deploys (`landing-page-staging`) intentionally do NOT trigger indexing, so
# the test environment is never submitted to search engines. The job is
# idempotent and follows the blog-indexing-automation skill: # idempotent and follows the blog-indexing-automation skill:
# #
# 1. Detect blog URLs added/modified in the deploy # 1. Detect blog URLs added/modified in the deploy
@ -16,7 +18,7 @@ name: blog-indexing-on-deploy
on: on:
workflow_run: workflow_run:
workflows: ['landing-page-deploy'] workflows: ['landing-page-production']
types: [completed] types: [completed]
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
@ -29,7 +31,10 @@ on:
required: false required: false
permissions: permissions:
contents: read # `contents: write` lets the job advance the `blog-indexed-prod` tag after a
# successful run so the next promotion diffs from exactly where this one
# stopped. The status PR uses a separate GitHub App token (below).
contents: write
concurrency: concurrency:
group: blog-indexing-on-deploy group: blog-indexing-on-deploy
@ -147,8 +152,21 @@ jobs:
mkdir -p .blog-indexing mkdir -p .blog-indexing
BASE="${{ github.event.inputs.base_sha || '' }}" BASE="${{ github.event.inputs.base_sha || '' }}"
if [ -z "$BASE" ]; then if [ -z "$BASE" ]; then
BASE="$(git rev-parse HEAD^)" # Diff from the last production deploy we already indexed, tracked
# by the `blog-indexed-prod` tag. Production is a manual promotion
# that may bundle several merged posts, so a HEAD^ diff would miss
# all but the last commit. The tag is advanced at the end of a
# successful run (see "Advance indexed-production pointer").
git fetch --no-tags origin "+refs/tags/blog-indexed-prod:refs/tags/blog-indexed-prod" 2>/dev/null || true
BASE="$(git rev-parse --verify --quiet 'refs/tags/blog-indexed-prod^{commit}' || true)"
if [ -n "$BASE" ]; then
echo "Diffing from last indexed production commit (blog-indexed-prod): $BASE"
else
BASE="$(git rev-parse HEAD^)"
echo "No blog-indexed-prod tag yet; bootstrapping from HEAD^: $BASE"
fi
fi fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/detect-changed-urls.ts \ pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/detect-changed-urls.ts \
--base "$BASE" \ --base "$BASE" \
--head HEAD \ --head HEAD \
@ -264,3 +282,20 @@ jobs:
Generated by `.github/workflows/blog-indexing-on-deploy.yml`. The Generated by `.github/workflows/blog-indexing-on-deploy.yml`. The
sidecar `docs/blog-indexing-status.json` is the canonical state; sidecar `docs/blog-indexing-status.json` is the canonical state;
the markdown file is rendered from it. the markdown file is rendered from it.
# Advance the pointer LAST, only after every post-deploy step above
# (detect, verify, submit, inspect, analytics, render, status PR)
# succeeded. `success()` is false if any earlier step failed, so a
# failure leaves the tag where it was and the next production run
# re-processes the same range rather than silently skipping posts.
# Skipped steps (count == 0, or GSC/bot not configured) do not count as
# failures, so the pointer still advances over an empty/partial range.
# Restricted to the real production-deploy trigger; an ad-hoc manual
# dispatch must not move the production baseline.
- name: Advance indexed-production pointer
if: success() && github.event_name == 'workflow_run'
run: |
HEAD_SHA="$(git rev-parse HEAD)"
git tag -f blog-indexed-prod "$HEAD_SHA"
git push -f origin "refs/tags/blog-indexed-prod"
echo "Advanced blog-indexed-prod -> $HEAD_SHA"

View file

@ -2,6 +2,7 @@ name: ci
on: on:
pull_request: pull_request:
merge_group:
# Release validation is owned by the release workflows rather than this CI # Release validation is owned by the release workflows rather than this CI
# workflow: `release-stable` has a verify job before publishing, and # workflow: `release-stable` has a verify job before publishing, and
# `release-beta` builds from its selected release commit. Keep this trigger # `release-beta` builds from its selected release commit. Keep this trigger
@ -73,19 +74,20 @@ jobs:
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_pack_tests_required=true tools_pack_tests_required=true
fi fi
if [[ "$file" == "package.json" || "$file" == "apps/"*/"package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then if [[ "$file" == "package.json" || "$file" == "apps/daemon/package.json" || "$file" == "apps/web/package.json" || "$file" == "apps/desktop/package.json" || "$file" == "apps/packaged/package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then
daemon_tests_required=true daemon_tests_required=true
web_tests_required=true web_tests_required=true
tools_dev_tests_required=true tools_dev_tests_required=true
tools_pack_tests_required=true tools_pack_tests_required=true
fi fi
if [[ "$file" == "package.json" || "$file" == "apps/"*/"package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" ]]; then # Keep this filter in sync with flake.nix daemonWorkspacePaths / webWorkspacePaths.
if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" || "$file" == ".github/workflows/nix-hash-autofix.yml" || "$file" == "apps/daemon/"* || "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/registry-protocol/"* || "$file" == "packages/agui-adapter/"* || "$file" == "packages/plugin-runtime/"* || "$file" == "packages/sidecar-proto/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/platform/"* || "$file" == "packages/diagnostics/"* || "$file" == "packages/host/"* || "$file" == "assets/"* || "$file" == "plugins/"* || "$file" == "skills/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* || "$file" == "prompt-templates/"* || "$file" == "scripts/update-nix-pnpm-deps-hash.ts" ]]; then
nix_validation_required=true nix_validation_required=true
fi fi
case "$file" in case "$file" in
*.md|*.mdx|*.txt|LICENSE|.gitignore|.editorconfig|.vscode/*|.idea/*|docs/*|.github/ISSUE_TEMPLATE/*|.github/CODEOWNERS) *.md|*.mdx|*.txt|LICENSE|.gitignore|.editorconfig|.vscode/*|.idea/*|docs/*|.github/ISSUE_TEMPLATE/*|.github/CODEOWNERS)
;; ;;
apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-deploy.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml) apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-staging.yml|.github/workflows/landing-page-production.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml|.github/workflows/blog-3day-report.yml|.github/workflows/seo-daily-report.yml|.github/workflows/actionlint.yml|.github/workflows/visual-pr-capture.yml|.github/workflows/visual-pr-comment.yml)
;; ;;
*) *)
workspace_validation_required=true workspace_validation_required=true
@ -106,6 +108,15 @@ jobs:
|| [ "$tools_pack_tests_required" = "true" ]; then || [ "$tools_pack_tests_required" = "true" ]; then
workspace_validation_required=true workspace_validation_required=true
fi fi
elif [ "${{ github.event_name }}" = "push" ]; then
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
# Main already runs .github/workflows/nix-check.yml, so keep this
# workflow's push path focused on the non-Nix workspace signal.
nix_validation_required=false
workspace_validation_required=true
else else
daemon_tests_required=true daemon_tests_required=true
web_tests_required=true web_tests_required=true
@ -141,9 +152,97 @@ jobs:
experimental-features = nix-command flakes experimental-features = nix-command flakes
accept-flake-config = true accept-flake-config = true
- name: Setup Node for Nix hash refresh
if: ${{ github.event_name == 'pull_request' }}
uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: nix flake check - name: nix flake check
id: flake_check
continue-on-error: true
run: nix flake check --print-build-logs --keep-going run: nix flake check --print-build-logs --keep-going
- name: Generate Nix hash refresh patch
id: hash_refresh
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
shell: bash
run: |
set -euo pipefail
out_dir="$RUNNER_TEMP/nix-hash-refresh"
mkdir -p "$out_dir"
status="update-failed"
if node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts >"$out_dir/update.log" 2>&1; then
if git diff --quiet --exit-code -- nix/pnpm-deps.nix; then
status="no-change"
else
git diff -- nix/pnpm-deps.nix >"$out_dir/nix-pnpm-deps.patch"
cp nix/pnpm-deps.nix "$out_dir/pnpm-deps.nix"
status="patch-generated"
fi
fi
printf '{"status":"%s","runId":%s,"prNumber":%s,"headSha":"%s"}\n' \
"$status" \
'${{ github.run_id }}' \
'${{ github.event.pull_request.number }}' \
'${{ github.event.pull_request.head.sha }}' >"$out_dir/metadata.json"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Upload Nix hash refresh artifact
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' && steps.hash_refresh.outputs.status != 'no-change' }}
uses: actions/upload-artifact@v4
with:
name: nix-hash-refresh
path: ${{ runner.temp }}/nix-hash-refresh
if-no-files-found: error
retention-days: 14
- name: Summarize Nix hash refresh guidance
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
env:
HASH_REFRESH_STATUS: ${{ steps.hash_refresh.outputs.status }}
shell: bash
run: |
set -euo pipefail
case "${HASH_REFRESH_STATUS:-not-run}" in
patch-generated)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Generated Nix hash refresh
CI regenerated a patch for `nix/pnpm-deps.nix` and uploaded it as the
`nix-hash-refresh` artifact for this run.
- same-repo PRs: the follow-up `nix-hash-autofix` workflow will try to
push the hash-only patch back to the PR branch automatically;
- fork PRs: a PR comment will include the patch and artifact guidance.
EOF
;;
no-change)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Nix hash refresh unavailable
`nix flake check` failed, but `nix/pnpm-deps.nix` did not change after
running the hash refresh helper. Inspect the Nix build logs for a
non-hash failure.
EOF
;;
*)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Nix hash refresh failed
`nix flake check` failed and the helper could not generate a hash-only
patch. See the `nix-hash-refresh` artifact for `update.log`.
EOF
;;
esac
- name: Fail when Nix validation fails
if: ${{ steps.flake_check.outcome == 'failure' }}
run: exit 1
preflight: preflight:
name: Preflight name: Preflight
needs: [change_scopes] needs: [change_scopes]
@ -155,30 +254,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that # `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared # are needed immediately after install for linked bins and shared
@ -212,8 +289,8 @@ jobs:
- name: Check i18n structure - name: Check i18n structure
run: pnpm i18n:check run: pnpm i18n:check
core_tests: workspace_unit_tests:
name: Core package tests name: Workspace unit tests
needs: [change_scopes] needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -223,77 +300,16 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js - name: Workspace unit tests
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Core package tests
run: | run: |
pnpm --filter @open-design/contracts test pnpm --filter @open-design/contracts test
pnpm --filter @open-design/host test pnpm --filter @open-design/host test
pnpm --filter @open-design/platform test pnpm --filter @open-design/platform test
pnpm --filter @open-design/sidecar test pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/sidecar-proto test pnpm --filter @open-design/sidecar-proto test
tools_workspace_tests:
name: Tools workspace tests
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Tools workspace smoke tests
run: |
if [ "${{ needs.change_scopes.outputs.tools_dev_tests_required }}" = "true" ]; then if [ "${{ needs.change_scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-dev test pnpm --filter @open-design/tools-dev test
fi fi
@ -302,50 +318,24 @@ jobs:
fi fi
daemon_workspace_tests: daemon_workspace_tests:
name: Daemon workspace tests (${{ matrix.shard }}/2) name: Daemon workspace tests
needs: [change_scopes] needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }} if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prebuild daemon entrypoint declarations - name: Prebuild daemon entrypoint declarations
run: pnpm --filter @open-design/daemon build run: pnpm --filter @open-design/daemon build
- name: Daemon workspace tests - name: Daemon workspace tests
run: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts --shard=${{ matrix.shard }}/2 run: pnpm --filter @open-design/daemon test
web_workspace_tests: web_workspace_tests:
name: Web workspace tests name: Web workspace tests
@ -358,30 +348,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prebuild web sidecar declarations - name: Prebuild web sidecar declarations
run: pnpm --filter @open-design/web build:sidecar run: pnpm --filter @open-design/web build:sidecar
@ -389,164 +357,26 @@ jobs:
- name: Web workspace tests - name: Web workspace tests
run: pnpm --filter @open-design/web test run: pnpm --filter @open-design/web test
app_tests: browser_tests:
name: App workspace tests name: Browser tests
needs: needs:
- change_scopes - change_scopes
- tools_workspace_tests
- daemon_workspace_tests
- web_workspace_tests
if: ${{ always() && needs.change_scopes.result == 'success' && (needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' || needs.change_scopes.outputs.daemon_tests_required == 'true' || needs.change_scopes.outputs.web_tests_required == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check app workspace test jobs
env:
NEEDS_JSON: ${{ toJSON(needs) }}
TOOLS_REQUIRED: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
DAEMON_REQUIRED: ${{ needs.change_scopes.outputs.daemon_tests_required }}
WEB_REQUIRED: ${{ needs.change_scopes.outputs.web_tests_required }}
run: |
set -euo pipefail
echo "$NEEDS_JSON" | jq .
failures=()
if [ "$TOOLS_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')" != "success" ]; then
failures+=("tools_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')")
fi
if [ "$DAEMON_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')" != "success" ]; then
failures+=("daemon_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')")
fi
if [ "$WEB_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')" != "success" ]; then
failures+=("web_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')")
fi
if [ "${#failures[@]}" -gt 0 ]; then
printf 'App workspace validation failed:\n'
printf '%s\n' "${failures[@]}"
exit 1
fi
e2e_vitest:
name: E2E vitest
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Some Vitest-based smoke tests drive the browser directly through
# @playwright/test. Restore browser binaries without saving from CI runs;
# the key follows the @playwright/test version so browser revisions
# update with package bumps.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
uses: actions/cache/restore@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
ui_e2e_critical:
name: Playwright critical (${{ matrix.group }})
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- group: core
grep_flag: --grep-invert
grep_pattern: home starters|home hero
- group: starters
grep_flag: --grep
grep_pattern: home starters|home hero
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
with: with:
version: 10.33.2 package-json-path: e2e/package.json
install-command: pnpm -C e2e exec playwright install --with-deps chromium
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Restore Playwright browser binaries without saving from CI runs. The
# key follows the @playwright/test version so browser revisions update
# with package bumps.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
uses: actions/cache/restore@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
- name: Prebuild workspace type declarations - name: Prebuild workspace type declarations
run: | run: |
@ -554,10 +384,13 @@ jobs:
pnpm --filter @open-design/desktop build pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar pnpm --filter @open-design/web build:sidecar
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
- name: Playwright critical - name: Playwright critical
run: | run: |
pnpm -C e2e exec tsx scripts/playwright.ts clean pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.grep_flag }} '${{ matrix.grep_pattern }}' ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts pnpm -C e2e exec playwright test -c playwright.config.ts ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
build_workspaces: build_workspaces:
name: Build workspaces name: Build workspaces
@ -570,30 +403,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v6.0.8 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prebuild workspace type declarations - name: Prebuild workspace type declarations
run: | run: |
@ -615,10 +426,10 @@ jobs:
- change_scopes - change_scopes
- preflight - preflight
- nix_validation - nix_validation
- core_tests - workspace_unit_tests
- app_tests - daemon_workspace_tests
- e2e_vitest - web_workspace_tests
- ui_e2e_critical - browser_tests
- build_workspaces - build_workspaces
if: ${{ always() }} if: ${{ always() }}
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -6,10 +6,8 @@ name: Contributor Card Bot
# #
# Intentionally NOT included: pull_request_review, pull_request_review_comment, # Intentionally NOT included: pull_request_review, pull_request_review_comment,
# issue_comment. GitHub withholds repository secrets from these events when # issue_comment. GitHub withholds repository secrets from these events when
# they originate on forked PRs, which is precisely the path most external # they originate on forked PRs, so the relay secret would fail-closed there too.
# contributor activity takes; the bot requires BOT_APP_* secrets to authenticate, # They can be re-added later via a workflow_run handoff.
# so wiring those events here would fail-closed exactly for the contributors we
# want to recognize. They can be re-added later via a workflow_run handoff.
on: on:
pull_request_target: pull_request_target:
types: [closed] types: [closed]
@ -24,21 +22,16 @@ on:
permissions: permissions:
contents: read contents: read
# Serialize all bot runs across the whole repository. The bot reads-then-writes # Serialize all relay runs across the whole repository. The Cloudflare worker
# `data/contributor-card-state.json`; running events in parallel let multiple # owns durable state now, but queuing still keeps identical GitHub events from
# runs read the same SHA and only the first PUT succeeds, the rest fail with a # racing each other and producing duplicate comments during bursty merge windows.
# 409 Conflict. They also let the same actor receive duplicate cards when a
# burst of events fires before the first state write lands. A single repo-wide
# group with `cancel-in-progress: false` queues runs and processes them in
# arrival order, so every event still gets a card and the state file is never
# stale on write.
concurrency: concurrency:
group: contributor-card-bot group: contributor-card-bot
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
recognize: recognize:
name: Render and post contributor card name: Relay contributor event to Cloudflare worker
if: | if: |
github.repository == 'nexu-io/open-design' && github.repository == 'nexu-io/open-design' &&
( (
@ -49,32 +42,46 @@ jobs:
github.event_name == 'workflow_dispatch' github.event_name == 'workflow_dispatch'
) )
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 8 timeout-minutes: 5
steps: steps:
- name: Checkout contributor bot - name: Relay event payload
uses: actions/checkout@v6.0.2
with:
repository: nexu-io/open-design-bot-sandbox
ref: main
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6.0.0
with:
node-version: 22
cache: pnpm
- name: Install bot dependencies
run: pnpm install --frozen-lockfile
- name: Run contributor bot
env: env:
BOT_APP_ID: ${{ secrets.BOT_APP_ID }} CONTRIBUTOR_CARD_WORKER_URL: ${{ secrets.CONTRIBUTOR_CARD_WORKER_URL }}
BOT_APP_INSTALLATION_ID: ${{ secrets.BOT_APP_INSTALLATION_ID }} CONTRIBUTOR_CARD_WORKER_SECRET: ${{ secrets.CONTRIBUTOR_CARD_WORKER_SECRET }}
BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} GITHUB_EVENT_NAME: ${{ github.event_name }}
run: pnpm exec tsx scripts/action-handler.ts GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ACTION: ${{ github.event.action }}
GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}
GITHUB_EVENT_PATH: ${{ github.event_path }}
run: |
python - <<'PY'
import json, os
from pathlib import Path
payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
envelope = {
"repository": os.environ["GITHUB_REPOSITORY"],
"eventName": os.environ["GITHUB_EVENT_NAME"],
"action": os.environ.get("GITHUB_EVENT_ACTION") or payload.get("action"),
"deliveryId": f"{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}",
"triggeredAt": __import__("datetime").datetime.utcnow().isoformat(timespec="milliseconds") + "Z",
"payload": payload,
}
Path("body.json").write_text(json.dumps(envelope, separators=(",", ":")))
PY
python - <<'PY'
import hashlib, hmac, os
from pathlib import Path
body = Path("body.json").read_bytes()
digest = hmac.new(os.environ["CONTRIBUTOR_CARD_WORKER_SECRET"].encode(), body, hashlib.sha256).hexdigest()
Path("signature.txt").write_text(f"sha256={digest}")
PY
curl --fail --silent --show-error \
-X POST \
-H "content-type: application/json" \
-H "x-open-design-signature: $(cat signature.txt)" \
--data-binary @body.json \
"$CONTRIBUTOR_CARD_WORKER_URL/api/github/events"

View file

@ -0,0 +1,57 @@
name: fork-pr-workflow-approval
# This workflow runs in the trusted base-repository context. It never checks out
# or executes the fork head; the TypeScript policy script only reads PR metadata
# through the GitHub API and approves pending pull_request runs when the touched
# paths are inside the low-risk source allowlist. It only approves low-privilege
# pull_request workflows (`ci`, visual verify, and strict web-source visual
# capture); privileged
# workflow_run / release / deploy workflows stay on manual gates.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to evaluate.
required: true
type: string
dry_run:
description: Log decisions without approving workflow runs.
required: false
default: false
type: boolean
permissions:
actions: write
contents: read
pull-requests: read
concurrency:
group: fork-pr-workflow-approval-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false
jobs:
approve:
if: github.repository == 'nexu-io/open-design' && (github.event_name != 'pull_request_target' || github.event.action != 'edited' || github.event.changes.base != null)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout trusted base code
uses: actions/checkout@v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
- name: Evaluate and approve low-risk fork PR workflows
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: node --experimental-strip-types scripts/approve-fork-pr-workflows.ts

View file

@ -5,7 +5,8 @@ on:
paths: paths:
# Workflow files # Workflow files
- .github/workflows/landing-page-ci.yml - .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page-deploy.yml - .github/workflows/landing-page-staging.yml
- .github/workflows/landing-page-production.yml
- .github/workflows/blog-indexing-on-deploy.yml - .github/workflows/blog-indexing-on-deploy.yml
- .github/workflows/blog-indexing-monitor.yml - .github/workflows/blog-indexing-monitor.yml
# Landing page sources # Landing page sources
@ -19,16 +20,29 @@ on:
- design-systems/** - design-systems/**
- craft/** - craft/**
- templates/** - templates/**
# Plugin manifests power the bundled-plugin catalog and the new
# `_lib/bundled-plugins.ts` reader; CI must rerun when their
# `title_i18n` / `description_i18n` maps or other fields change.
- plugins/**
# Workspace plumbing # Workspace plumbing
- package.json - package.json
- pnpm-lock.yaml - pnpm-lock.yaml
- pnpm-workspace.yaml - pnpm-workspace.yaml
# Merge queue trigger so PRs that touch the same paths can clear
# `Validate landing page` / `Strict PR visual tests` while queued.
# Without this branch ruleset blocks merges (the queue waits forever
# for a check name that never gets dispatched against the merge_group
# ref), which is the exact deadlock observed during the 5/26 release
# window.
merge_group:
types: [checks_requested]
push: push:
branches: branches:
- main - main
paths: paths:
- .github/workflows/landing-page-ci.yml - .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page-deploy.yml - .github/workflows/landing-page-staging.yml
- .github/workflows/landing-page-production.yml
- .github/workflows/blog-indexing-on-deploy.yml - .github/workflows/blog-indexing-on-deploy.yml
- .github/workflows/blog-indexing-monitor.yml - .github/workflows/blog-indexing-monitor.yml
- apps/landing-page/** - apps/landing-page/**
@ -37,6 +51,7 @@ on:
- design-systems/** - design-systems/**
- craft/** - craft/**
- templates/** - templates/**
- plugins/**
- package.json - package.json
- pnpm-lock.yaml - pnpm-lock.yaml
- pnpm-workspace.yaml - pnpm-workspace.yaml
@ -44,6 +59,10 @@ on:
permissions: permissions:
contents: read contents: read
# Needed to post/update the preview-URL comment on the PR. Fork PRs run
# with a read-only token regardless, so the preview steps below are gated
# to same-repo branches.
pull-requests: write
concurrency: concurrency:
group: landing-page-ci-${{ github.event.pull_request.number || github.ref }} group: landing-page-ci-${{ github.event.pull_request.number || github.ref }}
@ -61,37 +80,26 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup pnpm - name: Setup workspace
uses: pnpm/action-setup@v5 uses: ./.github/actions/setup-workspace
with:
version: 10.33.2
- name: Setup Node.js - name: Cache generated previews
uses: actions/setup-node@v6 id: previews-cache
uses: actions/cache@v5.0.5
with: with:
node-version: 24 path: apps/landing-page/public/previews
cache: pnpm key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
restore-keys: |
- name: Install dependencies landing-page-previews-${{ runner.os }}-
run: pnpm install --frozen-lockfile
# Cache the Playwright browser binaries between runs. The cache key # Cache the Playwright browser binaries between runs. The cache key
# is pinned to the playwright version we depend on (kept in # is pinned to the playwright version we depend on (kept in
# apps/landing-page/package.json) so a bump invalidates correctly. # apps/landing-page/package.json) so a bump invalidates correctly.
- name: Resolve Playwright version - name: Setup Playwright
id: playwright-version uses: ./.github/actions/setup-playwright
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v4
with: with:
path: ~/.cache/ms-playwright package-json-path: apps/landing-page/package.json
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} install-command: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page - name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck run: pnpm --filter @open-design/landing-page typecheck
@ -103,16 +111,30 @@ jobs:
# launch failure or a 100%-failure run exits non-zero so the # launch failure or a 100%-failure run exits non-zero so the
# build stops instead of silently shipping zero thumbnails. # build stops instead of silently shipping zero thumbnails.
- name: Generate skill + template previews - name: Generate skill + template previews
# Exact previews-cache hit ⇒ public/previews already holds the correct
# thumbnails, skip the slow Playwright render. A restore-keys partial
# hit keeps cache-hit false, so we still regenerate — no stale-thumbnail
# drift.
if: steps.previews-cache.outputs.cache-hit != 'true'
run: pnpm --filter @open-design/landing-page previews run: pnpm --filter @open-design/landing-page previews
# No PUBLIC_GA_MEASUREMENT_ID for PR/CI builds: the per-PR preview must
# not report into the production GA property. OD_LANDING_NOINDEX=1 keeps
# the PR preview (pr-<n>.open-design-landing-staging.pages.dev) out of
# search engines. Only `landing-page-production` builds without these.
- name: Build landing page - name: Build landing page
run: pnpm --filter @open-design/landing-page build env:
OD_LANDING_NOINDEX: '1'
run: pnpm --filter @open-design/landing-page build:static
- name: Lint changed blog SEO - name: Lint changed blog SEO
run: | run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}" BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="HEAD^" # merge_group (and first-push) events have no base SHA. Resolve a
# concrete commit instead of passing the literal "HEAD^", which the
# blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed).
BASE="$(git rev-parse HEAD^)"
fi fi
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/lint-blog-seo.ts \ pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/lint-blog-seo.ts \
--base "$BASE" \ --base "$BASE" \
@ -121,9 +143,12 @@ jobs:
- name: Guard blog URL changes - name: Guard blog URL changes
run: | run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}" BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="HEAD^" # merge_group (and first-push) events have no base SHA. Resolve a
# concrete commit instead of passing the literal "HEAD^", which the
# blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed).
BASE="$(git rev-parse HEAD^)"
fi fi
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/check-blog-url-changes.ts \ pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/check-blog-url-changes.ts \
--base "$BASE" \ --base "$BASE" \
@ -162,3 +187,74 @@ jobs:
process.exit(1); process.exit(1);
} }
NODE NODE
# --- PR preview deploy -------------------------------------------------
# Publish this PR's built site to its own preview URL in the STAGING
# project (`--branch=pr-<number>`) so reviewers see the rendered result
# before merge. It lands in the staging project, never the production
# project. Gated to same-repo branches: fork PRs run without the
# Cloudflare secrets and with a read-only token, so they skip the
# deploy/comment and keep just the validation above.
- name: Deploy PR preview to Cloudflare Pages
id: preview
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing-page
packageManager: npm
command: >
pages deploy out
--project-name=open-design-landing-staging
--branch=pr-${{ github.event.pull_request.number }}
- name: Comment preview URL on PR
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
env:
DEPLOY_URL: ${{ steps.preview.outputs.deployment-url }}
ALIAS_URL: ${{ steps.preview.outputs.pages-deployment-alias-url }}
with:
script: |
const marker = '<!-- landing-preview -->';
const deploy = process.env.DEPLOY_URL || '';
const alias =
process.env.ALIAS_URL ||
`https://pr-${context.issue.number}.open-design-landing-staging.pages.dev`;
const sha = context.payload.pull_request.head.sha.slice(0, 7);
const body = [
marker,
'### 🚀 Landing page preview',
'',
'This PR is deployed to a Cloudflare Pages preview — **not** staging or production:',
'',
`- Stable alias: ${alias}`,
deploy ? `- This build: ${deploy}` : '',
'',
`Updated for commit \`${sha}\`.`,
]
.filter(Boolean)
.join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

View file

@ -0,0 +1,161 @@
name: landing-page-production
# Promotes the current landing page to PRODUCTION: the `open-design-landing`
# Cloudflare Pages project, served at open-design.ai. This is the ONLY
# workflow that names the production project, and it is manual-only
# (workflow_dispatch) — a merge to `main` can never reach production on its
# own; it only updates the staging project (staging.open-design.ai) via
# `landing-page-staging`. Gate this further by configuring required reviewers
# on the GitHub `production` environment (Settings → Environments).
#
# The build is identical to staging/CI, so what you reviewed on
# staging.open-design.ai is what ships.
on:
workflow_dispatch:
inputs:
reason:
description: 'Why promote now? (recorded in the run log)'
required: false
permissions:
contents: read
deployments: write
# Never cancel an in-flight production deploy.
concurrency:
group: landing-page-production
cancel-in-progress: false
jobs:
deploy:
name: Deploy landing page to production
# Production ships `main` only. workflow_dispatch can be launched from any
# ref via the Actions "Use workflow from" dropdown; gate the whole job on
# the main ref so a dispatch from a feature branch/tag is skipped outright
# (no deploy) instead of recording a non-main production run — which would
# also dodge blog-indexing's `workflow_run` `branches: [main]` filter.
if: github.repository == 'nexu-io/open-design' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: production
url: https://open-design.ai
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
# Production always ships `main`. workflow_dispatch can be launched
# from any ref via the Actions "Use workflow from" dropdown, so pin
# the checkout to main — the deployed artifact must equal reviewed
# main, never whatever branch/tag the operator happened to select.
ref: main
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache generated previews
id: previews-cache
uses: actions/cache@v5.0.5
with:
path: apps/landing-page/public/previews
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
restore-keys: |
landing-page-previews-${{ runner.os }}-
- name: Cache Playwright browsers
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
# Generate previews before build so they end up in `out/previews/`.
# Soft vs. hard failure is enforced inside the script itself:
# individual broken `example.html` entries are logged and skipped,
# but a systemic failure (chromium launch error, every job failing)
# exits non-zero so we don't silently ship a deploy with zero
# thumbnails to production.
- name: Generate skill + template previews
# Exact previews-cache hit ⇒ public/previews already holds the correct
# thumbnails, skip the slow Playwright render. A restore-keys partial
# hit keeps cache-hit false, so we still regenerate — no stale-thumbnail
# drift.
if: steps.previews-cache.outputs.cache-hit != 'true'
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
env:
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
run: pnpm --filter @open-design/landing-page build:static
- name: Verify zero external JavaScript
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const forbidden = [
/<script\b[^>]*\bsrc=/i,
/type=["']module["']/i,
/\/_astro\/[^"'<>\s]+\.js/i,
];
for (const pattern of forbidden) {
if (pattern.test(html)) {
console.error(`Unexpected client JavaScript matched ${pattern}`);
process.exit(1);
}
}
NODE
- name: Verify Cloudflare image resizing URLs
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
if (resizedUrls.length < 16) {
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
process.exit(1);
}
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
console.error('Found local /assets/*.png image reference in generated landing HTML.');
process.exit(1);
}
NODE
# `--branch=main` IS the Cloudflare Pages production branch, so this
# publishes to the production domain (open-design.ai).
- name: Deploy to Cloudflare Pages (production)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing-page
packageManager: npm
command: >
pages deploy out
--project-name=open-design-landing
--branch=main

View file

@ -1,4 +1,11 @@
name: landing-page-deploy name: landing-page-staging
# Pushes to `main` deploy the landing page to the STAGING Cloudflare Pages
# project (`open-design-landing-staging`, served at staging.open-design.ai).
# Production (the separate `open-design-landing` project → open-design.ai) is
# never named here — it is reached exclusively through the manual
# `landing-page-production` workflow. This is the safety gate: project
# separation means a merge to main can only ever change staging.
on: on:
push: push:
@ -6,7 +13,8 @@ on:
- main - main
paths: paths:
# Workflow files # Workflow files
- .github/workflows/landing-page-deploy.yml - .github/workflows/landing-page-staging.yml
- .github/workflows/landing-page-production.yml
- .github/workflows/landing-page-ci.yml - .github/workflows/landing-page-ci.yml
# Landing page sources # Landing page sources
- apps/landing-page/** - apps/landing-page/**
@ -20,6 +28,11 @@ on:
- design-systems/** - design-systems/**
- craft/** - craft/**
- templates/** - templates/**
# Plugin marketplace — the registry JSON and bundled official plugin
# manifests are read at build time by plugin-registry.ts to render the
# /plugins catalogue. Merging a plugin PR MUST trigger a redeploy or the
# published catalogue silently falls behind.
- plugins/**
# Workspace plumbing # Workspace plumbing
- package.json - package.json
- pnpm-lock.yaml - pnpm-lock.yaml
@ -31,15 +44,18 @@ permissions:
deployments: write deployments: write
concurrency: concurrency:
group: landing-page-deploy-${{ github.ref }} group: landing-page-staging-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
deploy: deploy:
name: Deploy landing page name: Deploy landing page to staging
if: github.repository == 'nexu-io/open-design' if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
environment:
name: landing-staging
url: https://staging.open-design.ai
steps: steps:
- name: Checkout - name: Checkout
@ -65,8 +81,17 @@ jobs:
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')") version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache generated previews
id: previews-cache
uses: actions/cache@v5.0.5
with:
path: apps/landing-page/public/previews
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
restore-keys: |
landing-page-previews-${{ runner.os }}-
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v4 uses: actions/cache@v5.0.5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
@ -84,10 +109,26 @@ jobs:
# exits non-zero so we don't silently ship a deploy with zero # exits non-zero so we don't silently ship a deploy with zero
# thumbnails to production. # thumbnails to production.
- name: Generate skill + template previews - name: Generate skill + template previews
# Skip only on an EXACT previews-cache hit: the cache key hashes every
# rendering input, so an exact hit means public/previews already holds
# the correct thumbnails and regenerating is wasted work. A restore-keys
# partial hit leaves cache-hit unset (false), so we still regenerate
# then — no stale-thumbnail drift. This is what lets a plugins-only
# deploy skip the slowest step (Playwright screenshotting) while a
# skill/template change still re-renders.
if: steps.previews-cache.outputs.cache-hit != 'true'
run: pnpm --filter @open-design/landing-page previews run: pnpm --filter @open-design/landing-page previews
# No PUBLIC_GA_MEASUREMENT_ID on staging: leaving it unset omits the
# Google Analytics tag entirely, so test-environment traffic does not
# pollute the production GA property. OD_LANDING_NOINDEX=1 makes every
# page emit <meta name="robots" content="noindex"> so the staging mirror
# stays out of search engines. Only `landing-page-production` builds
# without these (GA on, indexable).
- name: Build landing page - name: Build landing page
run: pnpm --filter @open-design/landing-page build env:
OD_LANDING_NOINDEX: '1'
run: pnpm --filter @open-design/landing-page build:static
- name: Verify zero external JavaScript - name: Verify zero external JavaScript
run: | run: |
@ -123,7 +164,11 @@ jobs:
} }
NODE NODE
- name: Deploy to Cloudflare Pages # Deploys to the dedicated STAGING project. `--branch=main` is that
# project's production branch, so it serves at its custom domain
# staging.open-design.ai. The production project (open-design-landing)
# is a different project and is never named here.
- name: Deploy to Cloudflare Pages (staging)
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@ -132,5 +177,5 @@ jobs:
packageManager: npm packageManager: npm
command: > command: >
pages deploy out pages deploy out
--project-name=open-design-landing --project-name=open-design-landing-staging
--branch=${{ github.ref_name }} --branch=main

205
.github/workflows/nix-hash-autofix.yml vendored Normal file
View file

@ -0,0 +1,205 @@
name: nix-hash-autofix
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
actions: read
contents: read
pull-requests: write
jobs:
autofix:
if: ${{ github.repository == 'nexu-io/open-design' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
steps:
- name: Check for Nix hash refresh artifact
id: artifact
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
set -euo pipefail
artifact_id="$(gh api "repos/$REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "nix-hash-refresh") | .id' | head -n 1 || true)"
if [ -z "$artifact_id" ]; then
echo "present=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "present=true" >> "$GITHUB_OUTPUT"
echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
- name: Download Nix hash refresh artifact
if: ${{ steps.artifact.outputs.present == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }}
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/nix-hash-refresh"
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$REPO/actions/artifacts/$ARTIFACT_ID/zip" > "$RUNNER_TEMP/nix-hash-refresh.zip"
unzip -q "$RUNNER_TEMP/nix-hash-refresh.zip" -d "$RUNNER_TEMP/nix-hash-refresh"
- name: Read PR and patch metadata
if: ${{ steps.artifact.outputs.present == 'true' }}
id: meta
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || '' }}
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
set -euo pipefail
status="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1], "r", encoding="utf-8")); print(data.get("status", "missing"))' "$RUNNER_TEMP/nix-hash-refresh/metadata.json")"
artifact_head_sha="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1], "r", encoding="utf-8")); print(data.get("headSha", ""))' "$RUNNER_TEMP/nix-hash-refresh/metadata.json")"
pr_number="$WORKFLOW_PR_NUMBER"
if [ -z "$pr_number" ]; then
pr_number="$(gh api "repos/$REPO/commits/$WORKFLOW_HEAD_SHA/pulls" --jq '.[0].number // empty')"
fi
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
echo "Unable to derive PR number for workflow head $WORKFLOW_HEAD_SHA." >&2
exit 1
fi
pr_json="$(gh api "repos/$REPO/pulls/$pr_number")"
head_repo="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["repo"]["full_name"])')"
head_ref="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["ref"])')"
head_sha="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["sha"])')"
{
echo "status=$status"
echo "artifact_head_sha=$artifact_head_sha"
echo "pr_number=$pr_number"
echo "head_repo=$head_repo"
echo "head_ref=$head_ref"
echo "head_sha=$head_sha"
} >> "$GITHUB_OUTPUT"
if [ -n "$artifact_head_sha" ] && [ "$artifact_head_sha" = "$head_sha" ]; then
echo "head_sha_matches=true" >> "$GITHUB_OUTPUT"
else
echo "head_sha_matches=false" >> "$GITHUB_OUTPUT"
fi
if [ "$head_repo" = "$REPO" ] && [ "$status" = "patch-generated" ] && [ "$artifact_head_sha" = "$head_sha" ]; then
echo "can_autopush=true" >> "$GITHUB_OUTPUT"
else
echo "can_autopush=false" >> "$GITHUB_OUTPUT"
fi
- name: Detect bot credentials
if: ${{ steps.meta.outputs.can_autopush == 'true' }}
id: bot-secrets
env:
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }}
shell: bash
run: |
set -euo pipefail
if [ -n "${BOT_APP_ID}" ] && [ -n "${BOT_APP_PRIVATE_KEY}" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate Open Design bot token
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-secrets.outputs.present == 'true' }}
id: bot-token
continue-on-error: true
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: nexu-io
repositories: open-design
permission-contents: write
- name: Checkout PR branch for auto-apply
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' }}
uses: actions/checkout@v6.0.2
with:
repository: ${{ steps.meta.outputs.head_repo }}
ref: ${{ steps.meta.outputs.head_ref }}
token: ${{ steps.bot-token.outputs.token }}
- name: Apply generated hash patch
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' }}
id: apply
shell: bash
run: |
set -euo pipefail
patch_path="$RUNNER_TEMP/nix-hash-refresh/nix-pnpm-deps.patch"
if [ ! -f "$patch_path" ]; then
echo "applied=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git apply --check "$patch_path"
git apply "$patch_path"
changed_files="$(git diff --name-only)"
if [ "$changed_files" != "nix/pnpm-deps.nix" ]; then
echo "Unexpected files changed after applying hash patch:" >&2
printf '%s\n' "$changed_files" >&2
exit 1
fi
if git diff --quiet --exit-code; then
echo "applied=false" >> "$GITHUB_OUTPUT"
else
echo "applied=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push generated hash refresh
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' && steps.apply.outputs.applied == 'true' }}
shell: bash
run: |
set -euo pipefail
git config user.name 'open-design-bot[bot]'
git config user.email '282769551+open-design-bot[bot]@users.noreply.github.com'
git add nix/pnpm-deps.nix
git commit -m 'chore(nix): refresh pnpm deps hash'
git push origin "HEAD:${{ steps.meta.outputs.head_ref }}"
- name: Upsert fork/manual patch comment
if: ${{ steps.artifact.outputs.present == 'true' && steps.meta.outputs.head_sha_matches == 'true' && (steps.meta.outputs.can_autopush != 'true' || steps.bot-token.outcome != 'success' || steps.apply.outputs.applied != 'true') }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
HASH_REFRESH_STATUS: ${{ steps.meta.outputs.status }}
shell: bash
run: |
set -euo pipefail
body_file="$RUNNER_TEMP/nix-hash-refresh-comment.md"
patch_file="$RUNNER_TEMP/nix-hash-refresh/nix-pnpm-deps.patch"
marker='<!-- nix-hash-refresh -->'
{
printf '%s\n' "$marker"
if [ "${HASH_REFRESH_STATUS}" = 'patch-generated' ]; then
printf "A generated Nix hash refresh is available for this PR.\n\n"
printf "Apply the patch from the \`nix-hash-refresh\` artifact attached to the failed \`ci\` run, or paste the diff below into \`git apply\`:\n\n"
printf '```diff\n'
if [ -f "$patch_file" ]; then
cat "$patch_file"
else
printf '(artifact did not include nix-pnpm-deps.patch)\n'
fi
printf '\n```\n'
else
printf "The failed \`ci\` run attempted to refresh \`nix/pnpm-deps.nix\`, but it could not produce a hash-only patch.\n\n"
printf "Download the \`nix-hash-refresh\` artifact from that run and inspect \`update.log\` for details.\n"
fi
} > "$body_file"
comment_id="$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | contains(\"$marker\")) | .id" | tail -n 1 || true)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$REPO/issues/comments/$comment_id" --field body="$(cat "$body_file")" >/dev/null
else
gh api --method POST "repos/$REPO/issues/$PR_NUMBER/comments" --field body="$(cat "$body_file")" >/dev/null
fi

64
.github/workflows/release-beta-s.yml vendored Normal file
View file

@ -0,0 +1,64 @@
# Self-hosted release-beta lane. Hosted baseline remains release-beta.yml.
name: release-beta-s
on:
workflow_dispatch:
inputs:
enable_win:
description: "Run the Windows self-hosted beta lane."
required: true
type: boolean
default: true
publish:
description: "Publish release assets and metadata. Keep disabled while validating the runner lane."
required: true
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
group: release-beta-s-win
cancel-in-progress: false
jobs:
windows_probe:
name: Probe Windows self-hosted runner
if: ${{ inputs.enable_win }}
runs-on: [self-hosted, Windows, X64, nexu-win, release-beta]
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
lfs: true
- name: Verify preinstalled toolchain
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
where.exe git
where.exe git-lfs
where.exe node
where.exe pnpm
where.exe cargo
where.exe makensis
git --version
git lfs version
node --version
pnpm --version
cargo --version
makensis /VERSION
- name: Confirm workflow boundary
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
"workspace=$env:GITHUB_WORKSPACE"
"runner_temp=$env:RUNNER_TEMP"
"runner_tool_cache=$env:RUNNER_TOOL_CACHE"
if ($env:GITHUB_WORKSPACE -like "C:\Users\runner\Projects\*") {
throw "GITHUB_WORKSPACE must not point at the operator Projects directory"
}

View file

@ -41,6 +41,13 @@ env:
# no events leave the user's machine. # no events leave the user's machine.
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
# to PostHog after `next build` and before the .map files are stripped
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
# and the helper still strips .map to keep source out of the installer.
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
jobs: jobs:
metadata: metadata:
@ -170,6 +177,7 @@ jobs:
--mac-compression normal --mac-compression normal
--to dmg --to dmg
--json --json
--require-vela-cli
--signed --signed
) )
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
@ -262,6 +270,8 @@ jobs:
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
OD_PACKAGED_E2E_MAC: "1" OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_NAMESPACE: release-beta OD_PACKAGED_E2E_NAMESPACE: release-beta
OD_PACKAGED_E2E_RELEASE_CHANNEL: beta
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: | run: |
@ -450,7 +460,7 @@ jobs:
"--namespace", "release-beta-win", "--namespace", "release-beta-win",
"--portable", "--portable",
"--app-version", "${{ needs.metadata.outputs.beta_version }}", "--app-version", "${{ needs.metadata.outputs.beta_version }}",
"--to", "nsis", "--to", "all",
"--json" "--json"
) )
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
@ -468,7 +478,7 @@ jobs:
--namespace release-beta-win ` --namespace release-beta-win `
--portable ` --portable `
--app-version "${{ needs.metadata.outputs.beta_version }}" ` --app-version "${{ needs.metadata.outputs.beta_version }}" `
--to nsis ` --to all `
--json --json
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
@ -498,6 +508,8 @@ jobs:
OD_PACKAGED_E2E_WIN: "1" OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL: "0" OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL: "0"
OD_PACKAGED_E2E_NAMESPACE: release-beta-win OD_PACKAGED_E2E_NAMESPACE: release-beta-win
OD_PACKAGED_E2E_RELEASE_CHANNEL: beta
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: | run: |

View file

@ -19,6 +19,13 @@ env:
# leave the user's machine, /api/analytics/config returns enabled=false). # leave the user's machine, /api/analytics/config returns enabled=false).
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
# to PostHog after `next build` and before the .map files are stripped
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
# and the helper still strips .map to keep source out of the installer.
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
jobs: jobs:
metadata: metadata:
@ -302,6 +309,7 @@ jobs:
with: with:
name: open-design-preview-mac-release-assets name: open-design-preview-mac-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_mac_intel: build_mac_intel:
name: Build preview mac intel x64 name: Build preview mac intel x64
@ -372,6 +380,7 @@ jobs:
with: with:
name: open-design-preview-mac-intel-release-assets name: open-design-preview-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_win: build_win:
name: Build preview win x64 name: Build preview win x64
@ -447,7 +456,7 @@ jobs:
"--namespace", "release-preview-win", "--namespace", "release-preview-win",
"--portable", "--portable",
"--app-version", "${{ needs.metadata.outputs.release_version }}", "--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "nsis", "--to", "all",
"--json" "--json"
) )
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
@ -465,7 +474,7 @@ jobs:
--namespace release-preview-win ` --namespace release-preview-win `
--portable ` --portable `
--app-version "${{ needs.metadata.outputs.release_version }}" ` --app-version "${{ needs.metadata.outputs.release_version }}" `
--to nsis ` --to all `
--json --json
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
@ -557,6 +566,7 @@ jobs:
with: with:
name: open-design-preview-win-release-assets name: open-design-preview-win-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_linux: build_linux:
name: Build preview linux x64 name: Build preview linux x64
@ -607,6 +617,7 @@ jobs:
with: with:
name: open-design-preview-linux-release-assets name: open-design-preview-linux-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
publish: publish:
name: Publish preview release name: Publish preview release
@ -716,6 +727,7 @@ jobs:
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
run: bash .github/scripts/release/r2/verify.sh run: bash .github/scripts/release/r2/verify.sh
- name: Publish summary - name: Publish summary
@ -732,8 +744,31 @@ jobs:
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
run: bash .github/scripts/release/r2/summary.sh run: bash .github/scripts/release/r2/summary.sh
- name: Cleanup workflow artifacts - name: Cleanup workflow artifacts
if: ${{ success() }} if: ${{ success() }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh run: bash .github/scripts/release/github/cleanup-artifacts.sh
cleanup_partial_release_assets:
name: Cleanup unpublished preview asset artifacts
needs:
- build_mac
- build_mac_intel
- build_win
- build_linux
- publish
if: ${{ always() && needs.publish.result != 'success' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Delete unpublished preview asset artifacts
env:
ARTIFACT_CLEANUP_DESCRIPTION: intermediate preview asset Actions artifacts from this unpublished run. Canonical manual downloads are only the R2 links in a successful publish summary
ARTIFACT_NAME_REGEX: "-release-assets$"
run: bash .github/scripts/release/github/cleanup-artifacts.sh

View file

@ -32,6 +32,13 @@ env:
# leave the user's machine, /api/analytics/config returns enabled=false). # leave the user's machine, /api/analytics/config returns enabled=false).
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
# to PostHog after `next build` and before the .map files are stripped
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
# and the helper still strips .map to keep source out of the installer.
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
jobs: jobs:
metadata: metadata:
@ -51,6 +58,9 @@ jobs:
channel: ${{ steps.stable.outputs.channel }} channel: ${{ steps.stable.outputs.channel }}
commit: ${{ steps.stable.outputs.commit }} commit: ${{ steps.stable.outputs.commit }}
github_release_enabled: ${{ steps.stable.outputs.github_release_enabled }} github_release_enabled: ${{ steps.stable.outputs.github_release_enabled }}
linux_namespace: ${{ steps.stable.outputs.linux_namespace }}
mac_intel_namespace: ${{ steps.stable.outputs.mac_intel_namespace }}
namespace: ${{ steps.stable.outputs.namespace }}
nightly_number: ${{ steps.stable.outputs.nightly_number }} nightly_number: ${{ steps.stable.outputs.nightly_number }}
previous_stable: ${{ steps.stable.outputs.previous_stable }} previous_stable: ${{ steps.stable.outputs.previous_stable }}
release_name: ${{ steps.stable.outputs.release_name }} release_name: ${{ steps.stable.outputs.release_name }}
@ -58,6 +68,7 @@ jobs:
stable_version: ${{ steps.stable.outputs.stable_version }} stable_version: ${{ steps.stable.outputs.stable_version }}
state_source: ${{ steps.stable.outputs.state_source }} state_source: ${{ steps.stable.outputs.state_source }}
version_tag: ${{ steps.stable.outputs.version_tag }} version_tag: ${{ steps.stable.outputs.version_tag }}
win_namespace: ${{ steps.stable.outputs.win_namespace }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
@ -82,7 +93,7 @@ jobs:
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
R2_ACCESS_PROBE_NAME: release-stable R2_ACCESS_PROBE_NAME: ${{ steps.stable.outputs.namespace }}
RELEASE_CHANNEL: ${{ inputs.channel }} RELEASE_CHANNEL: ${{ inputs.channel }}
run: bash .github/scripts/release/r2/check.sh run: bash .github/scripts/release/r2/check.sh
@ -211,9 +222,9 @@ jobs:
build_args=( build_args=(
exec tools-pack mac build exec tools-pack mac build
--dir "$tools_pack_dir" --dir "$tools_pack_dir"
--namespace release-stable --namespace "${{ needs.metadata.outputs.namespace }}"
--portable --portable
--app-version "${{ needs.metadata.outputs.stable_version }}" --app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal --mac-compression normal
--to all --to all
--json --json
@ -235,7 +246,11 @@ jobs:
output="$RUNNER_TEMP/mac-framework-diagnostics.txt" output="$RUNNER_TEMP/mac-framework-diagnostics.txt"
source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err" source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err"
source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)" source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-stable/builder/mac-arm64/Open Design.app/Contents/Frameworks/Electron Framework.framework" app_name="Open Design.app"
if [ "${{ needs.metadata.outputs.channel }}" = "nightly" ]; then
app_name="Open Design Nightly.app"
fi
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/${{ needs.metadata.outputs.namespace }}/builder/mac-arm64/$app_name/Contents/Frameworks/Electron Framework.framework"
dump_framework() { dump_framework() {
local label="$1" local label="$1"
@ -308,7 +323,7 @@ jobs:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/mac-tools-pack-build.json OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/mac-tools-pack-build.json
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
OD_PACKAGED_E2E_MAC: "1" OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_NAMESPACE: release-stable OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
@ -332,7 +347,7 @@ jobs:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }} RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
run: bash .github/scripts/release/assets/mac.sh run: bash .github/scripts/release/assets/mac.sh
- name: Upload mac release bundle - name: Upload mac release bundle
@ -340,6 +355,7 @@ jobs:
with: with:
name: open-design-release-mac-release-assets name: open-design-release-mac-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_mac_intel: build_mac_intel:
name: Build release mac intel x64 name: Build release mac intel x64
@ -390,9 +406,9 @@ jobs:
set -euo pipefail set -euo pipefail
pnpm exec tools-pack mac build \ pnpm exec tools-pack mac build \
--dir "$RUNNER_TEMP/tools-pack" \ --dir "$RUNNER_TEMP/tools-pack" \
--namespace release-stable-intel \ --namespace "${{ needs.metadata.outputs.mac_intel_namespace }}" \
--portable \ --portable \
--app-version "${{ needs.metadata.outputs.stable_version }}" \ --app-version "${{ needs.metadata.outputs.release_version }}" \
--mac-compression normal \ --mac-compression normal \
--to all \ --to all \
--json \ --json \
@ -403,7 +419,7 @@ jobs:
env: env:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-intel TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.mac_intel_namespace }}
run: bash .github/scripts/release/assets/mac-intel.sh run: bash .github/scripts/release/assets/mac-intel.sh
- name: Upload mac intel release bundle - name: Upload mac intel release bundle
@ -411,6 +427,7 @@ jobs:
with: with:
name: open-design-release-mac-intel-release-assets name: open-design-release-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_win: build_win:
name: Build release win x64 name: Build release win x64
@ -483,10 +500,10 @@ jobs:
"exec", "tools-pack", "win", "build", "exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir, "--dir", $toolsPackDir,
"--cache-dir", $cacheDir, "--cache-dir", $cacheDir,
"--namespace", "release-stable-win", "--namespace", "${{ needs.metadata.outputs.win_namespace }}",
"--portable", "--portable",
"--app-version", "${{ needs.metadata.outputs.stable_version }}", "--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "nsis", "--to", "all",
"--json" "--json"
) )
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
@ -501,10 +518,10 @@ jobs:
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir
$buildOutput = pnpm exec tools-pack win build ` $buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir ` --dir $toolsPackDir `
--namespace release-stable-win ` --namespace "${{ needs.metadata.outputs.win_namespace }}" `
--portable ` --portable `
--app-version "${{ needs.metadata.outputs.stable_version }}" ` --app-version "${{ needs.metadata.outputs.release_version }}" `
--to nsis ` --to all `
--json --json
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE" throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
@ -532,7 +549,7 @@ jobs:
env: env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
OD_PACKAGED_E2E_WIN: "1" OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_NAMESPACE: release-stable-win OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
@ -588,7 +605,7 @@ jobs:
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }} RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-win TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}
run: ./.github/scripts/release/assets/win.ps1 run: ./.github/scripts/release/assets/win.ps1
- name: Upload windows release bundle - name: Upload windows release bundle
@ -596,6 +613,7 @@ jobs:
with: with:
name: open-design-release-win-release-assets name: open-design-release-win-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
build_linux: build_linux:
name: Build release linux x64 name: Build release linux x64
@ -643,9 +661,9 @@ jobs:
build_args=( build_args=(
exec tools-pack linux build exec tools-pack linux build
--dir "$tools_pack_dir" --dir "$tools_pack_dir"
--namespace release-stable-linux --namespace "${{ needs.metadata.outputs.linux_namespace }}"
--portable --portable
--app-version "${{ needs.metadata.outputs.stable_version }}" --app-version "${{ needs.metadata.outputs.release_version }}"
--to appimage --to appimage
--containerized --containerized
--json --json
@ -663,7 +681,7 @@ jobs:
working-directory: e2e working-directory: e2e
env: env:
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1" OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
OD_PACKAGED_E2E_NAMESPACE: release-stable-linux OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: | run: |
@ -676,7 +694,7 @@ jobs:
"platform": "linux", "platform": "linux",
"releaseVersion": "${{ needs.metadata.outputs.release_version }}", "releaseVersion": "${{ needs.metadata.outputs.release_version }}",
"spec": "specs/linux.spec.ts", "spec": "specs/linux.spec.ts",
"namespace": "release-stable-linux", "namespace": "${{ needs.metadata.outputs.linux_namespace }}",
"screenshot": "screenshots/open-design-linux-smoke.png", "screenshot": "screenshots/open-design-linux-smoke.png",
"githubRunId": "$GITHUB_RUN_ID", "githubRunId": "$GITHUB_RUN_ID",
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT", "githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
@ -698,7 +716,7 @@ jobs:
- name: Prepare linux release assets - name: Prepare linux release assets
env: env:
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-stable-linux TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
run: bash .github/scripts/release/assets/linux.sh run: bash .github/scripts/release/assets/linux.sh
- name: Upload linux release bundle - name: Upload linux release bundle
@ -706,6 +724,7 @@ jobs:
with: with:
name: open-design-release-linux-release-assets name: open-design-release-linux-release-assets
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-assets
retention-days: 1
publish: publish:
name: Publish ${{ needs.metadata.outputs.channel }} release name: Publish ${{ needs.metadata.outputs.channel }} release
@ -869,6 +888,7 @@ jobs:
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
run: bash .github/scripts/release/r2/verify.sh run: bash .github/scripts/release/r2/verify.sh
- name: Promote draft to published latest - name: Promote draft to published latest
@ -902,8 +922,31 @@ jobs:
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
run: bash .github/scripts/release/r2/summary.sh run: bash .github/scripts/release/r2/summary.sh
- name: Cleanup workflow artifacts - name: Cleanup workflow artifacts
if: ${{ success() }} if: ${{ success() }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh run: bash .github/scripts/release/github/cleanup-artifacts.sh
cleanup_partial_release_assets:
name: Cleanup unpublished release asset artifacts
needs:
- build_mac
- build_mac_intel
- build_win
- build_linux
- publish
if: ${{ always() && needs.publish.result != 'success' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Delete unpublished release asset artifacts
env:
ARTIFACT_CLEANUP_DESCRIPTION: intermediate release asset Actions artifacts from this unpublished run. Canonical manual downloads are only the R2 links in a successful publish summary
ARTIFACT_NAME_REGEX: "-release-assets$"
run: bash .github/scripts/release/github/cleanup-artifacts.sh

View file

@ -103,11 +103,11 @@ jobs:
OPP_LOW_CTR: '0.01' OPP_LOW_CTR: '0.01'
OPP_MOBILE_DESKTOP_CTR_GAP: '0.30' OPP_MOBILE_DESKTOP_CTR_GAP: '0.30'
run: | run: |
flags="" flags=()
if [ -n "${{ github.event.inputs.today }}" ]; then if [ -n "${{ github.event.inputs.today }}" ]; then
flags="$flags --today ${{ github.event.inputs.today }}" flags+=(--today "${{ github.event.inputs.today }}")
fi fi
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
flags="$flags --dry-run" flags+=(--dry-run)
fi fi
pnpm --filter @open-design/landing-page exec tsx scripts/seo-daily-report.ts $flags pnpm --filter @open-design/landing-page exec tsx scripts/seo-daily-report.ts "${flags[@]}"

View file

@ -18,7 +18,6 @@ on:
permissions: permissions:
actions: read actions: read
contents: read contents: read
pull-requests: write
concurrency: concurrency:
group: visual-pr-capture-${{ github.event.pull_request.number }} group: visual-pr-capture-${{ github.event.pull_request.number }}
@ -73,132 +72,3 @@ jobs:
e2e/ui/reports/visual-report/manifest.json e2e/ui/reports/visual-report/manifest.json
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
comment_same_repo:
name: Comment PR visual screenshots
needs: [capture]
if: ${{ always() && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact
uses: actions/download-artifact@v8
with:
run-id: ${{ github.run_id }}
path: visual-artifact
merge-multiple: true
github-token: ${{ github.token }}
- name: Validate capture manifest
id: manifest
shell: bash
env:
EXPECTED_PR_NUMBER: ${{ github.event.pull_request.number }}
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
EXPECTED_RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
manifest="visual-artifact/visual-report/manifest.json"
if [ ! -f "$manifest" ]; then
manifest="visual-artifact/manifest.json"
fi
if [ ! -f "$manifest" ]; then
echo "Capture manifest not found" >&2
find visual-artifact -maxdepth 4 -type f >&2
exit 1
fi
pr_number="$(jq -r '.pr_number' "$manifest")"
head_sha="$(jq -r '.head_sha' "$manifest")"
base_sha="$(jq -r '.base_sha' "$manifest")"
run_id="$(jq -r '.run_id' "$manifest")"
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
if [ "$pr_number" != "$EXPECTED_PR_NUMBER" ] || [ "$head_sha" != "$EXPECTED_HEAD_SHA" ] || [ "$base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
echo "Capture manifest does not match the current pull_request event." >&2
jq . "$manifest" >&2
exit 1
fi
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
- name: Build visual diff report
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
run: |
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
--run-id "${{ steps.manifest.outputs.run_id }}" \
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
--screenshots ../visual-artifact/visual-screenshots \
--comment-out ui/reports/visual-report/comment.md \
--manifest-out ui/reports/visual-report/report-manifest.json
- name: Upsert PR comment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
run: |
set -euo pipefail
body_path="e2e/ui/reports/visual-report/comment.md"
comments_json="$RUNNER_TEMP/comments.json"
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
else
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
fi
- name: Upload visual report artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-report-${{ github.event.pull_request.number }}-${{ github.run_id }}
path: e2e/ui/reports/visual-report
if-no-files-found: ignore
retention-days: 7

View file

@ -27,15 +27,21 @@ concurrency:
jobs: jobs:
comment: comment:
name: Publish PR visual diff comment name: Publish PR visual diff comment
# Fork PR capture artifacts are untrusted. Always validate the live PR state
# and only execute trusted base-repository code before publishing comments
# or reports.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout - name: Checkout same-repository workflow head
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name == github.repository }}
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6.0.8 uses: pnpm/action-setup@v6.0.8
@ -48,25 +54,11 @@ jobs:
node-version: 24 node-version: 24
package-manager-cache: false package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact - name: Download PR visual artifact
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }} run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
path: visual-artifact path: ${{ runner.temp }}/visual-artifact
merge-multiple: true merge-multiple: true
github-token: ${{ github.token }} github-token: ${{ github.token }}
@ -77,16 +69,19 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }} WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
WORKFLOW_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }}
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }}
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }} WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
run: | run: |
set -euo pipefail set -euo pipefail
manifest="visual-artifact/visual-report/manifest.json" artifact_dir="$RUNNER_TEMP/visual-artifact"
manifest="$artifact_dir/visual-report/manifest.json"
if [ ! -f "$manifest" ]; then if [ ! -f "$manifest" ]; then
manifest="visual-artifact/manifest.json" manifest="$artifact_dir/manifest.json"
fi fi
if [ ! -f "$manifest" ]; then if [ ! -f "$manifest" ]; then
echo "Capture manifest not found" >&2 echo "Capture manifest not found" >&2
find visual-artifact -maxdepth 4 -type f >&2 find "$artifact_dir" -maxdepth 4 -type f >&2
exit 1 exit 1
fi fi
manifest_pr_number="$(jq -r '.pr_number' "$manifest")" manifest_pr_number="$(jq -r '.pr_number' "$manifest")"
@ -97,22 +92,6 @@ jobs:
echo "Invalid manifest pr_number: $manifest_pr_number" >&2 echo "Invalid manifest pr_number: $manifest_pr_number" >&2
exit 1 exit 1
fi fi
derived_pr_number="$WORKFLOW_PR_NUMBER"
if [ -z "$derived_pr_number" ] && [ -n "$WORKFLOW_HEAD_SHA" ]; then
derived_pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$WORKFLOW_HEAD_SHA/pulls" --jq '.[0].number // empty')"
fi
pr_number="$derived_pr_number"
if [ -z "$pr_number" ]; then
pr_number="$manifest_pr_number"
fi
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
echo "Unable to derive PR number from workflow event, capture manifest, or GitHub API for workflow head $WORKFLOW_HEAD_SHA." >&2
exit 1
fi
if [ -n "$derived_pr_number" ] && [ "$manifest_pr_number" != "$derived_pr_number" ]; then
echo "Manifest pr_number ($manifest_pr_number) does not match GitHub-derived PR number ($derived_pr_number)." >&2
exit 1
fi
if ! [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then if ! [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid manifest base_sha: $base_sha" >&2 echo "Invalid manifest base_sha: $base_sha" >&2
exit 1 exit 1
@ -125,23 +104,171 @@ jobs:
echo "Invalid manifest capture_outcome: $capture_outcome" >&2 echo "Invalid manifest capture_outcome: $capture_outcome" >&2
exit 1 exit 1
fi fi
source_head_sha="$WORKFLOW_HEAD_SHA"
source_head_repository="$WORKFLOW_HEAD_REPOSITORY"
source_head_branch="$WORKFLOW_HEAD_BRANCH"
if [ -z "$source_head_sha" ] || [ -z "$source_head_repository" ] || [ -z "$source_head_branch" ]; then
run_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$WORKFLOW_RUN_ID")"
if [ -z "$source_head_sha" ]; then
source_head_sha="$(jq -r '.head_sha // empty' <<< "$run_json")"
fi
if [ -z "$source_head_repository" ]; then
source_head_repository="$(jq -r '.head_repository.full_name // empty' <<< "$run_json")"
fi
if [ -z "$source_head_branch" ]; then
source_head_branch="$(jq -r '.head_branch // empty' <<< "$run_json")"
fi
fi
manifest_head="$(jq -r '.head_sha' "$manifest")" manifest_head="$(jq -r '.head_sha' "$manifest")"
if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid manifest head_sha: $manifest_head" >&2 echo "Invalid manifest head_sha: $manifest_head" >&2
exit 1 exit 1
fi fi
if [ -n "$WORKFLOW_HEAD_SHA" ] && [ "$manifest_head" != "$WORKFLOW_HEAD_SHA" ]; then if ! [[ "$source_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($WORKFLOW_HEAD_SHA)." >&2 echo "Unable to resolve trusted workflow_run head_sha for run $WORKFLOW_RUN_ID." >&2
exit 1 exit 1
fi fi
echo "path=$manifest" >> "$GITHUB_OUTPUT" if [ "$manifest_head" != "$source_head_sha" ]; then
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($source_head_sha)." >&2
echo "head_sha=$manifest_head" >> "$GITHUB_OUTPUT" exit 1
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" fi
echo "run_id=$run_id" >> "$GITHUB_OUTPUT" if [ -z "$source_head_repository" ]; then
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT" echo "Unable to resolve trusted workflow_run head repository for run $WORKFLOW_RUN_ID." >&2
exit 1
fi
if [ -z "$source_head_branch" ]; then
echo "Unable to resolve trusted workflow_run head branch for run $WORKFLOW_RUN_ID." >&2
exit 1
fi
pr_number="$WORKFLOW_PR_NUMBER"
if [ -z "$pr_number" ]; then
head_owner="${source_head_repository%%/*}"
if [ -z "$head_owner" ] || [ "$head_owner" = "$source_head_repository" ]; then
echo "Unable to resolve trusted workflow_run head owner from $source_head_repository." >&2
exit 1
fi
encoded_head="$(jq -rn --arg head "$head_owner:$source_head_branch" '$head|@uri')"
matching_pr_numbers="$(
gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$encoded_head&per_page=100" \
| jq -r --arg repo "$source_head_repository" \
'.[] | select((.head.repo.full_name // "") == $repo) | .number' \
| paste -sd ' ' -
)"
match_count="$(wc -w <<< "$matching_pr_numbers" | tr -d ' ')"
if [ "$match_count" -gt 1 ]; then
echo "Unable to resolve a unique open PR from trusted workflow metadata for $source_head_repository@$source_head_branch ($source_head_sha); found $match_count matches." >&2
exit 1
fi
if [ "$match_count" -eq 1 ]; then
pr_number="$matching_pr_numbers"
else
pr_number="$manifest_pr_number"
fi
fi
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
echo "Unable to derive PR number from trusted workflow metadata for workflow head $source_head_sha." >&2
exit 1
fi
if [ -n "$WORKFLOW_PR_NUMBER" ] && [ "$manifest_pr_number" != "$WORKFLOW_PR_NUMBER" ]; then
echo "Manifest pr_number ($manifest_pr_number) does not match workflow_run PR number ($WORKFLOW_PR_NUMBER)." >&2
exit 1
fi
{
echo "path=$manifest"
echo "pr_number=$pr_number"
echo "head_sha=$manifest_head"
echo "base_sha=$base_sha"
echo "run_id=$run_id"
echo "capture_outcome=$capture_outcome"
echo "source_head_repository=$source_head_repository"
echo "source_head_branch=$source_head_branch"
} >> "$GITHUB_OUTPUT"
- name: Validate live PR state for trusted checkout
id: trusted-pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
SOURCE_HEAD_REPOSITORY: ${{ steps.manifest.outputs.source_head_repository }}
SOURCE_HEAD_BRANCH: ${{ steps.manifest.outputs.source_head_branch }}
run: |
set -euo pipefail
skip_or_fail_stale() {
local message="$1"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "$message" >&2
exit 1
fi
echo "$message"
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
}
pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")"
pr_state="$(jq -r '.state' <<< "$pr_json")"
pr_is_draft="$(jq -r '.draft' <<< "$pr_json")"
pr_is_cross_repository="$(jq -r '(.head.repo.full_name // "") != (.base.repo.full_name // "")' <<< "$pr_json")"
current_head="$(jq -r '.head.sha' <<< "$pr_json")"
current_base="$(jq -r '.base.sha' <<< "$pr_json")"
current_head_repository="$(jq -r '.head.repo.full_name // empty' <<< "$pr_json")"
current_head_branch="$(jq -r '.head.ref // empty' <<< "$pr_json")"
if [ "$pr_state" != "open" ]; then
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because state is $pr_state."
fi
if [ "$pr_is_draft" != "false" ]; then
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because it is draft."
fi
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
skip_or_fail_stale "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
fi
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
skip_or_fail_stale "Skipping stale visual artifact for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
fi
if [ "$current_head_repository" != "$SOURCE_HEAD_REPOSITORY" ]; then
echo "Artifact PR head repository ($current_head_repository) does not match trusted workflow_run head repository ($SOURCE_HEAD_REPOSITORY)." >&2
exit 1
fi
if [ "$current_head_branch" != "$SOURCE_HEAD_BRANCH" ]; then
echo "Artifact PR head branch ($current_head_branch) does not match trusted workflow_run head branch ($SOURCE_HEAD_BRANCH)." >&2
exit 1
fi
{
echo "stale=false"
echo "base_sha=$current_base"
echo "is_cross_repository=$pr_is_cross_repository"
} >> "$GITHUB_OUTPUT"
- name: Checkout trusted base revision
if: ${{ steps.trusted-pr.outputs.stale != 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.head_repository.full_name != github.repository) }}
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
repository: ${{ github.repository }}
ref: ${{ steps.trusted-pr.outputs.base_sha }}
- name: Resolve pnpm store path
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
run: pnpm install --frozen-lockfile
- name: Stop stale visual runs - name: Stop stale visual runs
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
id: stale id: stale
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@ -150,9 +277,21 @@ jobs:
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }} ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)" pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
pr_state="$(jq -r '.state' <<< "$pr_json")"
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
current_head="$(jq -r '.headRefOid' <<< "$pr_json")" current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")" current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
if [ "$pr_state" != "OPEN" ]; then
echo "Skipping visual report for PR $PR_NUMBER because state is $pr_state."
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$pr_is_draft" != "false" ]; then
echo "Skipping visual report for PR $PR_NUMBER because it is draft."
echo "stale=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
echo "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head." echo "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
echo "stale=true" >> "$GITHUB_OUTPUT" echo "stale=true" >> "$GITHUB_OUTPUT"
@ -166,7 +305,7 @@ jobs:
echo "stale=false" >> "$GITHUB_OUTPUT" echo "stale=false" >> "$GITHUB_OUTPUT"
- name: Build visual diff report - name: Build visual diff report
if: ${{ steps.stale.outputs.stale != 'true' }} if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
env: env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@ -185,12 +324,12 @@ jobs:
--head-sha "${{ steps.manifest.outputs.head_sha }}" \ --head-sha "${{ steps.manifest.outputs.head_sha }}" \
--base-sha "${{ steps.manifest.outputs.base_sha }}" \ --base-sha "${{ steps.manifest.outputs.base_sha }}" \
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \ --capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
--screenshots ../visual-artifact/visual-screenshots \ --screenshots "$RUNNER_TEMP/visual-artifact/visual-screenshots" \
--comment-out ui/reports/visual-report/comment.md \ --comment-out ui/reports/visual-report/comment.md \
--manifest-out ui/reports/visual-report/report-manifest.json --manifest-out ui/reports/visual-report/report-manifest.json
- name: Upsert PR comment - name: Upsert PR comment
if: ${{ steps.stale.outputs.stale != 'true' }} if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }} PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
@ -198,9 +337,19 @@ jobs:
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }} ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)" pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
pr_state="$(jq -r '.state' <<< "$pr_json")"
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
current_head="$(jq -r '.headRefOid' <<< "$pr_json")" current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")" current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
if [ "$pr_state" != "OPEN" ]; then
echo "Skipping visual comment for PR $PR_NUMBER because state is $pr_state."
exit 0
fi
if [ "$pr_is_draft" != "false" ]; then
echo "Skipping visual comment for PR $PR_NUMBER because it is draft."
exit 0
fi
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
echo "Skipping stale visual comment for $ARTIFACT_HEAD_SHA; current PR head is $current_head." echo "Skipping stale visual comment for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
exit 0 exit 0
@ -220,7 +369,7 @@ jobs:
fi fi
- name: Upload visual report artifact - name: Upload visual report artifact
if: ${{ always() && steps.stale.outputs.stale != 'true' }} if: ${{ always() && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }} name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }}

View file

@ -23,7 +23,6 @@ This file is the single source of truth for agents entering this repository. Rea
- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives. - `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives.
- `tools/dev` is the local development lifecycle control plane. - `tools/dev` is the local development lifecycle control plane.
- `tools/pack` is the local packaged build/start/stop/logs control plane, packaged updater harness, installer identity/registry validation surface, and mac beta release artifact preparation surface. - `tools/pack` is the local packaged build/start/stop/logs control plane, packaged updater harness, installer identity/registry validation surface, and mac beta release artifact preparation surface.
- `tools/pr` is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, lane checklists, and validation-command suggestions.
- `tools/serve` is the local fixture-service control plane; first service is `tools-serve start updater` for deterministic updater metadata and artifacts. - `tools/serve` is the local fixture-service control plane; first service is `tools-serve start updater` for deterministic updater metadata and artifacts.
- `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands. - `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands.
@ -58,8 +57,8 @@ This file is the single source of truth for agents entering this repository. Rea
## Root command boundary ## Root command boundary
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, `pnpm tools-pr`, and `pnpm tools-serve`. - Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, and `pnpm tools-serve`.
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`). - Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...`).
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`. - Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
## Release channel model ## Release channel model
@ -123,16 +122,11 @@ Every user-facing capability must be reachable through both the web UI **and** t
## PR-duty tooling ## PR-duty tooling
`pnpm tools-pr` is the maintainer-only control plane for PR-duty work on this repo. It is a thin `gh` wrapper that encodes repo-specific knowledge — review-lane derivation, forbidden-surface flags, per-lane checklists, validation-command suggestions, and a fixed dictionary of factual classify tags (`bot-only-approval`, `needs-rebase`, `stale-approval`, `unresolved-changes-requested`, `awaiting-*` timing, `org-member`, etc.). The tool is read-only on the PR surface: it never approves, merges, comments, or closes; those side effects stay in explicit `gh` invocations the maintainer runs. This repository no longer ships a maintainer PR-duty control plane. The former
`pnpm tools-pr` workflow has moved to the standalone `PerishCode/duty` project
Common subcommands: so personal review-lane automation does not become product workspace
maintenance surface. Do not recreate `tools/pr`, `@open-design/tools-pr`, or a
- `pnpm tools-pr list` — triage the open queue by lane and review-state bucket. root `pnpm tools-pr` script without a new explicit maintainer decision.
- `pnpm tools-pr view <num>` — factual review brief for a single PR.
- `pnpm tools-pr classify --all` — script-level tag JSON for the whole open queue (entry point for cron / digest consumers); per-PR `classify <num>` for spot checks.
- `pnpm tools-pr assignment` — assigner-perspective ownership + idle-time / blocker view across the queue.
For the full tag dictionary, operational playbook (direct merge / duplicate-title / awaiting-author / org-member / agent-review flows), comment templates, language-detection rules, and tool-design constraints (precision boundaries, factual-output rule, retry + pagination strategy), see [`tools/pr/AGENTS.md`](tools/pr/AGENTS.md).
## Agent runtime conventions ## Agent runtime conventions
@ -145,10 +139,19 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
## Chat UI conventions ## Chat UI conventions
- `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`. - `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. - TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. `PinnedTodoSlot` sits OUTSIDE the `.chat-log` scroll container, so auto-scroll requires explicit coverage: `ChatPane`'s `ResizeObserver` accepts a `containerRef` from `PinnedTodoSlot` and observes that element directly, and a pane-level `MutationObserver` (`childList: true` on the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive.
- `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape. - `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape.
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace). - Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace).
## Web CSS ownership
- `apps/web/src/index.css` is an import-only cascade entrypoint. Do not add selectors or declarations there; add imports only when a truly global stylesheet is needed, and keep import order intentional.
- Shared global styles belong in `apps/web/src/styles/`: design tokens, base/reset rules, primitives, app-shell layout, and legacy cross-component selectors that cannot safely be scoped yet. Keep domain-level global files grouped by owner (for example `styles/viewer/` and `styles/workspace/`) instead of adding more large files directly under `styles/`.
- New component-owned UI styles should default to CSS Modules next to the component (`Component.module.css`) instead of expanding global stylesheets. This is preferred for isolated components, panels, menus, drawers, toolbars, cards, and form sections.
- When touching an existing component with nearby global styles, prefer migrating that component's local selectors to a CSS Module as part of the change if it is small and testable. Do not mix a large mechanical move with behavior/styling changes in the same patch.
- Keep global class names only for deliberate shared contracts: reusable primitives, theme hooks, third-party/content styling, cross-component layout, or selectors that rely on global cascade/specificity. Document any new global selector group with its owning feature.
- CSS refactors must preserve cascade semantics. For mechanical splits, verify expanded import content/order matches the previous stylesheet; for CSS Module migrations, validate the affected UI path with `pnpm --filter @open-design/web typecheck` and a focused build/test or visual check when practical.
## i18n keys ## i18n keys
- `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 18 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error. - `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 18 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error.
@ -164,7 +167,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
## Validation strategy ## Validation strategy
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh. - After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
- Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. Use `pnpm nix:update-hash` to regenerate `nix/pnpm-deps.nix`, then re-run `nix flake check --print-build-logs --keep-going` (or rely on the PR `Validate workspace` gate if Nix is unavailable locally). - Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. `nix/pnpm-deps.nix` is a generated lock artifact; use `pnpm nix:update-hash` only when intentionally maintaining Nix packaging, then re-run `nix flake check --print-build-logs --keep-going`. Contributors without Nix can rely on the PR `Validate workspace` gate, which now uploads or auto-applies the generated hash-only fix when possible.
- Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases. - Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases.
- For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`. - For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`. - On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`.
@ -207,14 +210,6 @@ pnpm guard
pnpm typecheck pnpm typecheck
``` ```
```bash
pnpm tools-pr list
pnpm tools-pr list --bucket=merge-ready,approved-blocked
pnpm tools-pr list --lane=skill,contract --json
pnpm tools-pr view 1180
pnpm tools-pr view 1180 --json
```
```bash ```bash
pnpm --filter @open-design/web typecheck pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test pnpm --filter @open-design/web test
@ -224,7 +219,6 @@ pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build pnpm --filter @open-design/desktop build
pnpm --filter @open-design/tools-dev build pnpm --filter @open-design/tools-dev build
pnpm --filter @open-design/tools-pack build pnpm --filter @open-design/tools-pack build
pnpm --filter @open-design/tools-pr build
pnpm --filter @open-design/tools-serve build pnpm --filter @open-design/tools-serve build
``` ```

View file

@ -7,73 +7,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.8.0] - 2026-05-20
The rebuilt-core release: **everything is a plugin**, **headless by default**, **plugins create plugins**. Open Design's research-preview architecture has been replaced with a small, boring engine plus a plugin surface — design systems, slices, prototypes, exports, and Figma itself all live in plugins now. The desktop app is a thin wrapper around the OD CLI, so the same engine runs in Claude Code, OpenClaw, Hermes Agent, and chat bots in Lark / Discord / Slack. **Critique Theater** matures through **Phase 16** (rollout ratchet, conformance API, 9 Prometheus metrics, Grafana dashboard, M0 dark-launch by default). **149 design systems** now ship with structured `tokens.css` + components manifests across 60+ new brand fixtures. **Italian (it) locale** + **CJK font fallback**. New media providers: **Leonardo.ai**, **ElevenLabs**, **SenseAudio**. **Packaged auto-update** lands on both **macOS and Windows**, battle-hardened through the preview cycle. Plus a **top-to-bottom visual refresh**, **Quick-brief discovery overhaul**, **PostHog v2 analytics schema**, **manual edit UX overhaul** (focus mode, uploads, remove-element patch), **custom CLI agent profiles**, and **HTML Anything** landing page. 305 merged PRs by 75 contributors since 0.7.0.
### Added ### Added
- Critique Theater Settings toggle with i18n across 6 locales. ([#1484]) #### Plugin engine, registry & publishing
- Custom select web component primitive. ([#1714]) - **Plugin engine rebuild** with `packages/plugin-runtime`, `packages/registry-protocol`, and `packages/host` — the engine surfaces the plugin lifecycle through a small, neutral API so design systems, slices, prototypes, exports, and even Figma itself can live as plugins.
- AskUserQuestion tool wired through chat with TodoWrite pinning. ([#1743]) - **Plugin registry detail drawer** with trust badges and marketplace metadata. ([#2087])
- Structured tokens for Notion, Linear, GitHub design systems. ([#1825]) - **GitHub rate-limit fallback for marketplace plugins** keeps install / refresh flows reliable when GitHub API is throttled. ([#2064])
- Structured tokens for Cursor, Apple, Stripe design systems. ([#1831]) - **Plugin Publish-repo flow creates the author's repo correctly.** ([#2332], [#2363])
- OpenAI-compatible media providers in daemon. ([#1712]) - **CLI plugin publish reads manifest version** when the stored row is the `0.0.0` sentinel. ([#1903])
- Leonardo.ai image generation provider. ([#1123]) - **Block raw publish CLIs from the authoring summary** — keep agents on the OD publish path. ([#2380])
- Italian (`it`) locale support. ([#1323]) - **Demote Plugins + Integrations to the nav rail footer** so primary surface stays focused. ([#1806], [#2360], [#2397])
#### Critique Theater (Phases 9 16)
- **Phase 9** — drop-in mount wrapper, native i18n for `de` / `ja` / `ko` / `zh-TW`. ([#1315])
- **Phase 10** — daemon adapter conformance lab + degraded registry. ([#1316])
- **Phase 11** — Playwright stage suite (happy path, interrupt, 3 viewports, a11y). ([#1317], [#1483])
- **Phase 12** — 9 Prometheus metrics + 6 log events + OTel span + Grafana dashboard. ([#1485])
- **Phase 13** — reducer p99 benchmark + surface coverage walker. ([#1318])
- **Phase 15** — rollout resolver + Settings toggle hook. ([#1320])
- **Phase 16** — M-phase rollout ratchet + `/api/critique/conformance`. ([#1499])
- **Wireup with M0 dark-launch by default.** ([#1338])
- **Settings toggle** with dedicated section + i18n keys across 6 locales. ([#1484])
#### Design systems & tokens
- **Token channel default-on (PR-D)** so the new fixture pipeline is the default surface. ([#1544])
- **Structured `tokens.css` for 60+ new brands** across AI, devtool, SaaS, fintech, docs, consumer, hardware, cultural categories (Apple, Stripe, Airbnb, Vercel, Notion, Linear, GitHub, Figma, Slack, Discord, OpenAI, Shopify, Spotify, Uber, Cursor, and many more). ([#1652], [#1794], [#1841], [#2023], [#2028], [#2029], [#2033])
- **Token fixture catalog** — 20 brand + 20 product + remaining style fixtures, component-fixture coverage report. ([#2037], [#2040], [#2043], [#2049])
- **Component manifests** — extract + consume manifests for design systems. ([#2051])
- **Import design-system projects** via the discovery flow.
- **Perplexity design system.** ([#1747])
#### Agents, providers & media
- **Local custom CLI agent profiles** for arbitrary CLI agents. ([#378])
- **Leonardo.ai image provider.** ([#1123])
- **ElevenLabs audio support.** ([#1384])
- **SenseAudio TTS provider** + BYOK chat with image / video generation tools. ([#1633], [#2065])
- **User-configurable model alias for the media dispatcher.** ([#1277])
- **Cursor Agent live model id parsing** + auth diagnostics. ([#1538], [#2228])
#### Web UI
- **Manual edit UX overhaul** — focus mode, inline uploads, remove-element patch. ([#1516])
- **Manual edit inspector.** ([#1448])
- **Tweaks toolbar bound to the artifact panel** (toggle visibility from the panel chrome).
- **Custom select primitive** for cleaner dropdowns.
- **Collapsible comment side panel.**
- **Export as image** in the share menu.
- **Render GFM tables in markdown artifacts and chat.**
- **Surface saved Project instructions** for review and retrieval.
- **Copy-to-clipboard for user messages.**
- **Filter-by-kind dropdown** on the design-files viewer.
#### Discovery & onboarding
- **Quick-brief: collapse freeform clarification into a single form.** ([#2226])
- **Plugin inputs as authoritative Quick-brief answers.** ([#2243])
- **Stabilize discovery brand answers** in prompts. ([#1861])
- **Daemon surfaces discovery form answers to agents.** ([#2071])
#### Desktop & packaging
- **Packaged auto-update for both macOS and Windows.** ([#2362], [#2270], [#2403])
- **Updater hardening** through the preview cycle — release validation, deferred installer on Windows, applied-state clearing, download / install handoff hardening, smoke-recovery. ([#2565], [#2575], [#2592], [#2595], [#2677], [#2687], [#2700])
- **Desktop updater UI flow** — new in-app updater popup.
- **Packaged update apply observations** captured for telemetry / debugging. ([#2429])
- **Nightly + preview package identity** so beta installs don't collide with stable. ([#2437])
- **macOS Dock icon stays put** when desktop-pet window opens. ([#2413])
- **Refresh Open Design app visuals** — new app icons, logo, brand glyphs. ([#2436])
- **Linux packaged client parity smoke coverage.**
- **Ensure node binary dir is on PATH for agent sub-processes on Windows.** ([#1989])
#### Internationalization
- **Italian (it) locale** — full UI translation, brings supported languages to 19. ([#1323])
- **CJK font fallback** for Chinese / Japanese / Korean. ([#2227])
- **Refresh + polish French UI locale.**
- **Translate template platform selection + Companion surfaces to Chinese.** ([#1491])
- **Localize accent controls in settings**, comment-panel strings ([#1390], [#1392]), and skill validation messages.
#### Analytics, observability & infra
- **PostHog v2 event schema.** ([#2285])
- **Unify `page_name` + onboarding / design-system page_views.** ([#2390])
- **Upgrade `posthog-node` 4 → 5 in the daemon.** ([#2309])
- **One-click log export from Settings → About.**
#### Templates, landing & tutorials
- **HTML Anything page + responsive landing header.** ([#2452])
- **Rebuild `/templates` catalog from `design-templates`.** ([#2369])
- **Refresh templates + add tutorials channel** on the landing site. ([#2409])
- **Blog routes** on the landing site.
- **Search Console reporting workflows** + GSC report opportunities. ([#2388])
- **WeRead year-in-review HyperFrames template.**
### Changed ### Changed
- Packaged client lazy-loads Electron to enable headless config imports. ([#1798]) - **Critique Theater dark-launched at M0 by default**, gated through the new rollout ratchet so phases can be promoted independently.
- Claude design import canvas no longer zooms on scroll. ([#1726]) - **Plugin trust badges unified** across registry surfaces.
- Plugins and Integrations moved to the nav rail footer ([#1806], [#2360], [#2397]) — keep primary surface focused.
### Fixed ### Fixed
#### Web / UI #### Web
- Agent model select duplicate chevrons on macOS (wrapper+icon pattern). ([#1831]) - Block pitch-deck placeholder publishes and unbreak framework decks.
- Memory editor reveal after edit click. ([#1827]) - Rename FileViewer "Share" button to "Export".
- Memory preview action distinct from delete. ([#1813], follow-up [#1863]) - Confirm before deleting a saved template in New Project.
- Settings subtab-pill hover contrast in dark theme. ([#1815]) - Restore consistent app header layout on the entry view. ([#1519])
- Filter pill hover label readability across themes. ([#1828]) - Refine preview and project dropdown controls. ([#1514])
- Comment marker numbering in panels. ([#1826]) - Pin chat during content growth.
- Draw overlay scroll interaction. ([#1848]) - Auto-scroll feedback form.
- Plugin publish footer spacing. ([#1849]) - Routines history rows deep-link to their specific conversation. (Fixes [#1505])
- Picker hint clarity relative to comments panel. ([#1820]) - Hide resolved comments from preview overlays.
- Draw ink clears when exiting draw mode. ([#1821]) - Keep filter pill hover labels readable.
- Chrome action icon alignment. ([#1783]) - Improve replace-modal button hover contrast.
- Manual folder import error feedback. ([#1666]) - Freeze completed run durations across conversations.
- Template toolbar stickiness during scroll. ([#1785]) - Align Home prompt overlay with textarea so caret lands on click.
- Comment panel string localization. ([#1443]) - Restore release-light background. ([#1540])
- Resolved comments hidden from preview overlays. ([#1762]) - Allow downloads from preview iframes; fall back to srcDoc when HTML preview needs sandbox shim.
- HTML preview sandbox fallback to `srcDoc`. ([#1754]) - Coalesce chokidar rewrite bursts before refreshing files.
- BYOK chat inlines attached file context. ([#1730]) - Reveal memory editor after edit click; distinguish expanded memory preview action.
- Auto-annotate imported HTML elements for Tweaks selection. ([#892])
- Stable shared frame screen paths from referrer.
- Restore custom dropdown chevron for timezone selector in dark mode.
- Daemon run recovery across reloads. ([#2374])
#### Desktop #### Desktop & packaging
- "Export PDF" now opens a direct "Save as PDF" file dialog and writes the PDF to disk, instead of opening the macOS system print dialog. Fixes [#1774](https://github.com/nexu-io/open-design/issues/1774). - macOS Dock icon stays put when desktop-pet window opens. ([#2413])
- Align Windows smoke update root with portable installs. ([#2376])
- Nightly release smoke identity. ([#2446])
- Improve desktop updater ready UI. ([#2403])
- Forward proxy env vars to packaged sidecars.
- Detect mise-installed npm package bins.
- Launch Windows updater fixture via Node. ([#2364])
- Desktop "Export PDF" opens a direct "Save as PDF" file dialog and writes the PDF to disk, instead of opening the macOS system print dialog. (Fixes [#1774])
- macOS close exits fullscreen before hiding.
- Daemon's external-browser opener fixed on Windows.
#### Daemon #### Daemon, runtime & connectivity
- Claude connection smoke wraps stdin properly. ([#1844]) - Surface discovery form answers to agents. ([#2071])
- BYOK proxy honors IP-literal `OD_ALLOWED_ORIGINS` in no-Origin Host check. ([#1775]) - Stabilize discovery brand answers in prompts. ([#1861])
- ACP stage timeout aligned to outer chat inactivity window. ([#1743]) - ACP model detection timeout is configurable.
- Wrap Claude smoke test stdin as stream-json.
- Preserve Claude tool inputs. ([#1476])
- Codex CLI path fallback UX. ([#1205])
- Treat Codex reconnect events as warnings, not fatal errors. ([#1482])
- ACP config options used for model selection. ([#1208])
- Remove OpenCode stdin dash sentinel; soft empty API response handling.
- Forward external MCP servers to OpenCode.
#### Documentation ### Documentation
- Korean README desktop/background startup paragraph. ([#1876])
- Windows troubleshooting link synced across 12 locale READMEs. ([#1875])
- 0.8.0-preview banner pointing to Discussion [#1727]. ([#1781])
- Clarify that packaged macOS support includes a verified Intel x64 ZIP path on Monterey, and document the Finder `PATH` caveat for packaged CLI detection. Fixes [#327](https://github.com/nexu-io/open-design/issues/327).
#### Packaging - Critique Theater Phase 14 user guide + 2 AGENTS module maps. ([#1319])
- Nix flake `pnpmDepsHash` refresh after merging main. ([#1765]) - Windows native setup notes in `AGENTS.md`.
- Comprehensive contributor guide in `TRANSLATIONS.md`.
- RTL_LOCALES UI guidance + `es-ES` alignment.
- Sync `zh-TW` README with the English version.
- Sync Windows troubleshooting link across locale READMEs.
- Refresh contributors wall + GitHub metrics SVG.
- Clarify Intel Mac ZIP packaging support (includes the Monterey verified path and the Finder `PATH` caveat for packaged CLI detection). (Fixes [#327])
- README inventory badges sync — skills 31 → 131, design-systems 72 → 149. ([#1899])
- 0.8.0-preview banner + Discussion #1727 pointer. ([#1781])
- Active 0.8.0 contributors point at `main`. ([#1846])
### Security ### Internal
- DNS-rebinding SSRF prevented by resolving hostname before approving external API base URLs. ([#1176]) - Critique Theater Playwright stage suite (happy, interrupt, 3 viewports, a11y). ([#1317], [#1483])
- Tightened `LiveArtifactSsePayload.refreshStatus` to the canonical `LiveArtifactRefreshStatus` enum, preventing future REST↔SSE type drift. ([#1871]) - Reducer p99 bench + surface coverage walker. ([#1318])
- `nix-check.yml` workflow scoped to `permissions: contents: read`, matching the rest of the workflow suite. ([#1870]) - Harden e2e extended coverage state assertions. ([#2245])
- Visual regression PR workflow (CI).
### Internal / Tests - Component manifest extraction + daemon consume path. ([#2051])
- OD CLI wraps GitHub CLI (so plugins create plugins).
- Italian locale cleanup (`onImportFolder` signature + stale pet rail keys). ([#1814]) - `pnpm i18n:coverage` informational report.
- Linux packaged client parity smoke coverage. ([#1204]) - Issue templates: bug, feature, preview/v0.8.0 + chooser config. ([#1708])
- Metrics PRs trigger required checks. ([#1801])
- Packaged-linux runtime logs captured into headless artifact. ([#1823])
- Memory preview icon assertion decoupled for test stability. ([#1863])
## [0.7.0] - 2026-05-12 ## [0.7.0] - 2026-05-12
@ -1006,7 +1110,8 @@ First public release of Open Design — a local-first, open-source alternative t
- Beta release workflow placeholder. ([#36]) - Beta release workflow placeholder. ([#36])
- Git commit co-author policy. ([#131]) - Git commit co-author policy. ([#131])
[Unreleased]: https://github.com/nexu-io/open-design/compare/open-design-v0.7.0...HEAD [Unreleased]: https://github.com/nexu-io/open-design/compare/open-design-v0.8.0...HEAD
[0.8.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.8.0
[0.7.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.7.0 [0.7.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.7.0
[0.6.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.6.0 [0.6.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.6.0
[0.5.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.5.0 [0.5.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.5.0
@ -1515,7 +1620,6 @@ First public release of Open Design — a local-first, open-source alternative t
[#1105]: https://github.com/nexu-io/open-design/pull/1105 [#1105]: https://github.com/nexu-io/open-design/pull/1105
[#1115]: https://github.com/nexu-io/open-design/pull/1115 [#1115]: https://github.com/nexu-io/open-design/pull/1115
[#1117]: https://github.com/nexu-io/open-design/pull/1117 [#1117]: https://github.com/nexu-io/open-design/pull/1117
[#1123]: https://github.com/nexu-io/open-design/pull/1123
[#1126]: https://github.com/nexu-io/open-design/pull/1126 [#1126]: https://github.com/nexu-io/open-design/pull/1126
[#1128]: https://github.com/nexu-io/open-design/pull/1128 [#1128]: https://github.com/nexu-io/open-design/pull/1128
[#1132]: https://github.com/nexu-io/open-design/pull/1132 [#1132]: https://github.com/nexu-io/open-design/pull/1132
@ -1529,13 +1633,11 @@ First public release of Open Design — a local-first, open-source alternative t
[#1150]: https://github.com/nexu-io/open-design/pull/1150 [#1150]: https://github.com/nexu-io/open-design/pull/1150
[#1156]: https://github.com/nexu-io/open-design/pull/1156 [#1156]: https://github.com/nexu-io/open-design/pull/1156
[#1159]: https://github.com/nexu-io/open-design/pull/1159 [#1159]: https://github.com/nexu-io/open-design/pull/1159
[#1176]: https://github.com/nexu-io/open-design/pull/1176
[#1171]: https://github.com/nexu-io/open-design/pull/1171 [#1171]: https://github.com/nexu-io/open-design/pull/1171
[#1173]: https://github.com/nexu-io/open-design/pull/1173 [#1173]: https://github.com/nexu-io/open-design/pull/1173
[#1183]: https://github.com/nexu-io/open-design/pull/1183 [#1183]: https://github.com/nexu-io/open-design/pull/1183
[#1188]: https://github.com/nexu-io/open-design/pull/1188 [#1188]: https://github.com/nexu-io/open-design/pull/1188
[#1203]: https://github.com/nexu-io/open-design/pull/1203 [#1203]: https://github.com/nexu-io/open-design/pull/1203
[#1204]: https://github.com/nexu-io/open-design/pull/1204
[#1206]: https://github.com/nexu-io/open-design/pull/1206 [#1206]: https://github.com/nexu-io/open-design/pull/1206
[#1205]: https://github.com/nexu-io/open-design/pull/1205 [#1205]: https://github.com/nexu-io/open-design/pull/1205
[#1207]: https://github.com/nexu-io/open-design/pull/1207 [#1207]: https://github.com/nexu-io/open-design/pull/1207
@ -1563,7 +1665,6 @@ First public release of Open Design — a local-first, open-source alternative t
[#1300]: https://github.com/nexu-io/open-design/pull/1300 [#1300]: https://github.com/nexu-io/open-design/pull/1300
[#1307]: https://github.com/nexu-io/open-design/pull/1307 [#1307]: https://github.com/nexu-io/open-design/pull/1307
[#1308]: https://github.com/nexu-io/open-design/pull/1308 [#1308]: https://github.com/nexu-io/open-design/pull/1308
[#1323]: https://github.com/nexu-io/open-design/pull/1323
[#1328]: https://github.com/nexu-io/open-design/pull/1328 [#1328]: https://github.com/nexu-io/open-design/pull/1328
[#1330]: https://github.com/nexu-io/open-design/pull/1330 [#1330]: https://github.com/nexu-io/open-design/pull/1330
[#1161]: https://github.com/nexu-io/open-design/pull/1161 [#1161]: https://github.com/nexu-io/open-design/pull/1161
@ -1579,40 +1680,97 @@ First public release of Open Design — a local-first, open-source alternative t
[#1402]: https://github.com/nexu-io/open-design/pull/1402 [#1402]: https://github.com/nexu-io/open-design/pull/1402
[#1439]: https://github.com/nexu-io/open-design/pull/1439 [#1439]: https://github.com/nexu-io/open-design/pull/1439
[#1442]: https://github.com/nexu-io/open-design/pull/1442 [#1442]: https://github.com/nexu-io/open-design/pull/1442
[#1443]: https://github.com/nexu-io/open-design/pull/1443 [#327]: https://github.com/nexu-io/open-design/issues/327
[#378]: https://github.com/nexu-io/open-design/pull/378
[#892]: https://github.com/nexu-io/open-design/pull/892
[#1123]: https://github.com/nexu-io/open-design/pull/1123
[#1277]: https://github.com/nexu-io/open-design/pull/1277
[#1315]: https://github.com/nexu-io/open-design/pull/1315
[#1316]: https://github.com/nexu-io/open-design/pull/1316
[#1317]: https://github.com/nexu-io/open-design/pull/1317
[#1318]: https://github.com/nexu-io/open-design/pull/1318
[#1319]: https://github.com/nexu-io/open-design/pull/1319
[#1320]: https://github.com/nexu-io/open-design/pull/1320
[#1323]: https://github.com/nexu-io/open-design/pull/1323
[#1338]: https://github.com/nexu-io/open-design/pull/1338
[#1384]: https://github.com/nexu-io/open-design/pull/1384
[#1390]: https://github.com/nexu-io/open-design/pull/1390
[#1392]: https://github.com/nexu-io/open-design/pull/1392
[#1448]: https://github.com/nexu-io/open-design/pull/1448
[#1476]: https://github.com/nexu-io/open-design/pull/1476
[#1482]: https://github.com/nexu-io/open-design/pull/1482
[#1483]: https://github.com/nexu-io/open-design/pull/1483
[#1484]: https://github.com/nexu-io/open-design/pull/1484 [#1484]: https://github.com/nexu-io/open-design/pull/1484
[#1666]: https://github.com/nexu-io/open-design/pull/1666 [#1485]: https://github.com/nexu-io/open-design/pull/1485
[#1712]: https://github.com/nexu-io/open-design/pull/1712 [#1491]: https://github.com/nexu-io/open-design/pull/1491
[#1714]: https://github.com/nexu-io/open-design/pull/1714 [#1499]: https://github.com/nexu-io/open-design/pull/1499
[#1726]: https://github.com/nexu-io/open-design/pull/1726 [#1505]: https://github.com/nexu-io/open-design/issues/1505
[#1727]: https://github.com/nexu-io/open-design/discussions/1727 [#1514]: https://github.com/nexu-io/open-design/pull/1514
[#1730]: https://github.com/nexu-io/open-design/pull/1730 [#1516]: https://github.com/nexu-io/open-design/pull/1516
[#1743]: https://github.com/nexu-io/open-design/pull/1743 [#1519]: https://github.com/nexu-io/open-design/pull/1519
[#1754]: https://github.com/nexu-io/open-design/pull/1754 [#1538]: https://github.com/nexu-io/open-design/pull/1538
[#1762]: https://github.com/nexu-io/open-design/pull/1762 [#1540]: https://github.com/nexu-io/open-design/pull/1540
[#1765]: https://github.com/nexu-io/open-design/pull/1765 [#1544]: https://github.com/nexu-io/open-design/pull/1544
[#1775]: https://github.com/nexu-io/open-design/pull/1775 [#1633]: https://github.com/nexu-io/open-design/pull/1633
[#1652]: https://github.com/nexu-io/open-design/pull/1652
[#1708]: https://github.com/nexu-io/open-design/pull/1708
[#1747]: https://github.com/nexu-io/open-design/pull/1747
[#1774]: https://github.com/nexu-io/open-design/issues/1774
[#1781]: https://github.com/nexu-io/open-design/pull/1781 [#1781]: https://github.com/nexu-io/open-design/pull/1781
[#1783]: https://github.com/nexu-io/open-design/pull/1783 [#1794]: https://github.com/nexu-io/open-design/pull/1794
[#1785]: https://github.com/nexu-io/open-design/pull/1785 [#1806]: https://github.com/nexu-io/open-design/pull/1806
[#1798]: https://github.com/nexu-io/open-design/pull/1798 [#1841]: https://github.com/nexu-io/open-design/pull/1841
[#1801]: https://github.com/nexu-io/open-design/pull/1801 [#1846]: https://github.com/nexu-io/open-design/pull/1846
[#1813]: https://github.com/nexu-io/open-design/pull/1813 [#1861]: https://github.com/nexu-io/open-design/pull/1861
[#1814]: https://github.com/nexu-io/open-design/pull/1814 [#1899]: https://github.com/nexu-io/open-design/pull/1899
[#1815]: https://github.com/nexu-io/open-design/pull/1815 [#1903]: https://github.com/nexu-io/open-design/pull/1903
[#1820]: https://github.com/nexu-io/open-design/pull/1820 [#1989]: https://github.com/nexu-io/open-design/pull/1989
[#1821]: https://github.com/nexu-io/open-design/pull/1821 [#2023]: https://github.com/nexu-io/open-design/pull/2023
[#1823]: https://github.com/nexu-io/open-design/pull/1823 [#2028]: https://github.com/nexu-io/open-design/pull/2028
[#1825]: https://github.com/nexu-io/open-design/pull/1825 [#2029]: https://github.com/nexu-io/open-design/pull/2029
[#1826]: https://github.com/nexu-io/open-design/pull/1826 [#2033]: https://github.com/nexu-io/open-design/pull/2033
[#1827]: https://github.com/nexu-io/open-design/pull/1827 [#2037]: https://github.com/nexu-io/open-design/pull/2037
[#1828]: https://github.com/nexu-io/open-design/pull/1828 [#2040]: https://github.com/nexu-io/open-design/pull/2040
[#1831]: https://github.com/nexu-io/open-design/pull/1831 [#2043]: https://github.com/nexu-io/open-design/pull/2043
[#1844]: https://github.com/nexu-io/open-design/pull/1844 [#2049]: https://github.com/nexu-io/open-design/pull/2049
[#1848]: https://github.com/nexu-io/open-design/pull/1848 [#2051]: https://github.com/nexu-io/open-design/pull/2051
[#1849]: https://github.com/nexu-io/open-design/pull/1849 [#2064]: https://github.com/nexu-io/open-design/pull/2064
[#1863]: https://github.com/nexu-io/open-design/pull/1863 [#2065]: https://github.com/nexu-io/open-design/pull/2065
[#1870]: https://github.com/nexu-io/open-design/pull/1870 [#2071]: https://github.com/nexu-io/open-design/pull/2071
[#1871]: https://github.com/nexu-io/open-design/pull/1871 [#2087]: https://github.com/nexu-io/open-design/pull/2087
[#1875]: https://github.com/nexu-io/open-design/pull/1875 [#2226]: https://github.com/nexu-io/open-design/pull/2226
[#1876]: https://github.com/nexu-io/open-design/pull/1876 [#2227]: https://github.com/nexu-io/open-design/pull/2227
[#2228]: https://github.com/nexu-io/open-design/pull/2228
[#2243]: https://github.com/nexu-io/open-design/pull/2243
[#2245]: https://github.com/nexu-io/open-design/pull/2245
[#2264]: https://github.com/nexu-io/open-design/pull/2264
[#2270]: https://github.com/nexu-io/open-design/pull/2270
[#2285]: https://github.com/nexu-io/open-design/pull/2285
[#2309]: https://github.com/nexu-io/open-design/pull/2309
[#2332]: https://github.com/nexu-io/open-design/pull/2332
[#2360]: https://github.com/nexu-io/open-design/pull/2360
[#2362]: https://github.com/nexu-io/open-design/pull/2362
[#2363]: https://github.com/nexu-io/open-design/pull/2363
[#2364]: https://github.com/nexu-io/open-design/pull/2364
[#2369]: https://github.com/nexu-io/open-design/pull/2369
[#2374]: https://github.com/nexu-io/open-design/pull/2374
[#2376]: https://github.com/nexu-io/open-design/pull/2376
[#2380]: https://github.com/nexu-io/open-design/pull/2380
[#2388]: https://github.com/nexu-io/open-design/pull/2388
[#2390]: https://github.com/nexu-io/open-design/pull/2390
[#2397]: https://github.com/nexu-io/open-design/pull/2397
[#2403]: https://github.com/nexu-io/open-design/pull/2403
[#2409]: https://github.com/nexu-io/open-design/pull/2409
[#2413]: https://github.com/nexu-io/open-design/pull/2413
[#2429]: https://github.com/nexu-io/open-design/pull/2429
[#2436]: https://github.com/nexu-io/open-design/pull/2436
[#2437]: https://github.com/nexu-io/open-design/pull/2437
[#2446]: https://github.com/nexu-io/open-design/pull/2446
[#2452]: https://github.com/nexu-io/open-design/pull/2452
[#2565]: https://github.com/nexu-io/open-design/pull/2565
[#2575]: https://github.com/nexu-io/open-design/pull/2575
[#2592]: https://github.com/nexu-io/open-design/pull/2592
[#2595]: https://github.com/nexu-io/open-design/pull/2595
[#2677]: https://github.com/nexu-io/open-design/pull/2677
[#2687]: https://github.com/nexu-io/open-design/pull/2687
[#2700]: https://github.com/nexu-io/open-design/pull/2700

View file

@ -52,6 +52,34 @@ _Avoid_: generic subject field, hidden prompt text
The default voice option shown when the Home Audio composer cannot load configured ElevenLabs voices. It keeps ElevenLabs speech runnable by selecting the same default voice id the daemon uses when no explicit voice is supplied. The default voice option shown when the Home Audio composer cannot load configured ElevenLabs voices. It keeps ElevenLabs speech runnable by selecting the same default voice id the daemon uses when no explicit voice is supplied.
_Avoid_: required credential setup, empty voice selector _Avoid_: required credential setup, empty voice selector
**AMR Cloud**:
The user-facing cloud runtime option for Open Design's official model router, shown in onboarding and login-oriented product surfaces.
_Avoid_: Vela, local CLI label
**AMR CLI**:
The local command-line runtime adapter used to run AMR from an installed or packaged native CLI.
_Avoid_: AMR Cloud, cloud account
**AMR CLI Distribution Contract**:
The separately owned release contract that provides the native AMR CLI builds Open Design can package.
_Avoid_: Open Design release channel, package build step, source checkout
**AMR CLI Distribution Slice**:
The set of native AMR CLI platforms currently available through the distribution contract; platforms outside the slice do not bundle the AMR CLI.
_Avoid_: Open Design supported platforms, release channel, future platform promise
**AMR Account Status**:
Whether the user has authenticated the account needed to use AMR Cloud.
_Avoid_: profile badge, environment, CLI version
**AMR Environment Profile**:
The target AMR service environment a packaged runtime is configured to use.
_Avoid_: release channel, account status, app identity
**Onboarding Skip**:
The explicit path that lets a user leave onboarding without completing the currently selected setup option.
_Avoid_: continue, finish setup, passive close
## Relationships ## Relationships
- A **Project** contains zero or more **Normal Artifacts**. - A **Project** contains zero or more **Normal Artifacts**.
@ -62,6 +90,12 @@ _Avoid_: required credential setup, empty voice selector
- A **Home Composer Media Surface** maps user intent to an existing project kind and project metadata at submit time. - A **Home Composer Media Surface** maps user intent to an existing project kind and project metadata at submit time.
- The **Chip Rail** is the visible Home entry point for choosing a **Home Composer Media Surface**. - The **Chip Rail** is the visible Home entry point for choosing a **Home Composer Media Surface**.
- **Essential Audio Generation** uses an **Audio Source Field** plus model options before creating an audio **Project**. - **Essential Audio Generation** uses an **Audio Source Field** plus model options before creating an audio **Project**.
- **AMR Cloud** is the user-facing product choice; **AMR CLI** is the local execution adapter behind that capability.
- The **AMR CLI Distribution Contract** is owned separately from Open Design; Open Design release packaging consumes it instead of defining the native CLI release itself.
- The first **AMR CLI Distribution Slice** is mac arm64 only.
- **AMR Account Status** describes account readiness for **AMR Cloud**, not the environment profile or CLI installation state.
- An **AMR Environment Profile** is independent from release channel identity; a beta, preview, nightly, or stable package can target different AMR service environments when explicitly configured.
- **Onboarding Skip** bypasses setup completion requirements that belong to the normal onboarding continue path.
## Example dialogue ## Example dialogue

View file

@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # Web-Paket bei Bedarf bauen pnpm --filter @open-design/web build # Web-Paket bei Bedarf bauen
``` ```
Node `~24` und pnpm `10.33.x` sind erforderlich. `nvm` / `fnm` sind optional; nutzen Sie `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24`, wenn Sie Node so verwalten. macOS, Linux und WSL2 sind die primären Pfade. Windows nativ sollte funktionieren, ist aber kein primäres Ziel. Node `~24` und pnpm `10.33.x` sind erforderlich. `nvm` / `fnm` sind optional; nutzen Sie `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24`, wenn Sie Node so verwalten. macOS, Linux und WSL2 sind die primären Pfade. Windows nativ wird unterstützt; siehe [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) für die häufigsten Setup-Stolpersteine.
Sie brauchen keine Agent-CLI im `PATH`, um OD selbst zu entwickeln. Der daemon meldet dann "no agents found" und fällt auf den **Anthropic API · BYOK** Pfad zurück, der oft die schnellste Dev-Schleife ist. Sie brauchen keine Agent-CLI im `PATH`, um OD selbst zu entwickeln. Der daemon meldet dann "no agents found" und fällt auf den **Anthropic API · BYOK** Pfad zurück, der oft die schnellste Dev-Schleife ist.
@ -217,6 +217,7 @@ Außerdem:
- **Ein Anliegen pro PR.** - **Ein Anliegen pro PR.**
- **Titel ist imperativ + Scope.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`. - **Titel ist imperativ + Scope.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Nutzen Sie das PR-Template.** Füllen Sie jeden Abschnitt von [`.github/pull_request_template.md`](.github/pull_request_template.md) aus — Why, What users will see, Surface area, Screenshots (bei UI), Bug fix verification (bei Bugfix), Validation. Leere Abschnitte ergeben einen "please fill in"-Kommentar.
- **Body erklärt das Warum.** Der Diff zeigt oft das Was, aber selten den Grund. - **Body erklärt das Warum.** Der Diff zeigt oft das Was, aber selten den Grund.
- **Issue referenzieren**, falls vorhanden. Bei nicht-trivialen PRs ohne Issue bitte zuerst eines öffnen. - **Issue referenzieren**, falls vorhanden. Bei nicht-trivialen PRs ohne Issue bitte zuerst eines öffnen.
- **Während Review nicht squashen.** Fixups pushen; wir squashen beim Merge. - **Während Review nicht squashen.** Fixups pushen; wir squashen beim Merge.

View file

@ -48,8 +48,8 @@ Node `~24` et pnpm `10.33.x` sont requis. `nvm` / `fnm` sont optionnels ;
utilisez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` si utilisez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` si
vous gérez Node comme cela. macOS, Linux et WSL2 sont les environnements vous gérez Node comme cela. macOS, Linux et WSL2 sont les environnements
principaux pris en charge. principaux pris en charge.
Windows natif devrait fonctionner, mais ce n'est pas la cible principale : Windows natif est supporté ; voir [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)
ouvrez une issue si ce n'est pas le cas. pour les pièges de configuration les plus courants.
Vous n'avez pas besoin d'une CLI d'agent dans votre `PATH` pour développer OD. Vous n'avez pas besoin d'une CLI d'agent dans votre `PATH` pour développer OD.
Le daemon indiquera "no agents found" ; utilisez alors le mode API/BYOK Le daemon indiquera "no agents found" ; utilisez alors le mode API/BYOK
@ -357,6 +357,11 @@ Au-delà de ça :
dépendance : ce sont trois PR. dépendance : ce sont trois PR.
- **Titre impératif + scope.** `add dating-web skill`, - **Titre impératif + scope.** `add dating-web skill`,
`fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`. `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Utilisez le template de PR.** Remplissez chaque section de
[`.github/pull_request_template.md`](.github/pull_request_template.md) — Why,
What users will see, Surface area, Screenshots (si UI), Bug fix verification
(si correctif), Validation. Les sections vides recevront un commentaire
« please fill in ».
- **Le body explique le pourquoi.** Le diff montre souvent le quoi ; le pourquoi - **Le body explique le pourquoi.** Le diff montre souvent le quoi ; le pourquoi
est rarement évident. est rarement évident.
- **Référencez une issue** s'il y en a une. S'il n'y en a pas et que la PR est - **Référencez une issue** s'il y en a une. S'il n'y en a pas et que la PR est

View file

@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # 必要に応じて web パッケージをビルド pnpm --filter @open-design/web build # 必要に応じて web パッケージをビルド
``` ```
Node `~24` と pnpm `10.33.x` が必要です。`nvm` / `fnm` はオプション。使用する場合は `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。macOS、Linux、WSL2 が主要プラットフォームです。Windows ネイティブでも動作するはずですが、主要ターゲットではありません — 動作しない場合は issue を作成してください。 Node `~24` と pnpm `10.33.x` が必要です。`nvm` / `fnm` はオプション。使用する場合は `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。macOS、Linux、WSL2 が主要プラットフォームです。Windows ネイティブもサポートされています — 一般的なセットアップ時の落とし穴については [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) を参照してください。
OD 自体の開発に agent CLI は `PATH` 上に不要です — daemon は「no agents found」と表示し、**Anthropic API · BYOK** パスにフォールバックします。このパスが最も高速な開発ループです。 OD 自体の開発に agent CLI は `PATH` 上に不要です — daemon は「no agents found」と表示し、**Anthropic API · BYOK** パスにフォールバックします。このパスが最も高速な開発ループです。
@ -213,6 +213,7 @@ design-systems/your-brand/
- **PR 1 つにつき 1 つの関心事。** Skill の追加 + パーサーのリファクタリング + 依存関係のバンプは 3 つの PR です。 - **PR 1 つにつき 1 つの関心事。** Skill の追加 + パーサーのリファクタリング + 依存関係のバンプは 3 つの PR です。
- **タイトルは命令形 + スコープ。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。 - **タイトルは命令形 + スコープ。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。
- **PR テンプレートを使用する。** [`.github/pull_request_template.md`](.github/pull_request_template.md) の各セクションWhy、What users will see、Surface area、ScreenshotsUI の場合、Bug fix verificationバグ修正の場合、Validationをすべて埋めてください。空欄のセクションには "please fill in" のコメントが付きます。
- **本文は「なぜ」を説明。** 「何をするか」は通常 diff から明らかです。「なぜこれが必要か」はほとんどの場合そうではありません。 - **本文は「なぜ」を説明。** 「何をするか」は通常 diff から明らかです。「なぜこれが必要か」はほとんどの場合そうではありません。
- **issue がある場合は参照。** ない場合で、PR が自明でないなら、先に issue を作成して変更が求められていることを合意してから時間を費やしてください。 - **issue がある場合は参照。** ない場合で、PR が自明でないなら、先に issue を作成して変更が求められていることを合意してから時間を費やしてください。
- **レビュー中にスカッシュしない。** fixup をプッシュしてください。マージ時にスカッシュします。 - **レビュー中にスカッシュしない。** fixup をプッシュしてください。マージ時にスカッシュします。

View file

@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # build do pacote web quando necessário pnpm --filter @open-design/web build # build do pacote web quando necessário
``` ```
Node `~24` e pnpm `10.33.x` são obrigatórios. `nvm` / `fnm` são opcionais; use `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` se preferir gerenciar Node assim. macOS, Linux e WSL2 são os caminhos principais. Windows nativo costuma funcionar, mas não é alvo principal — abra uma issue se quebrar. Node `~24` e pnpm `10.33.x` são obrigatórios. `nvm` / `fnm` são opcionais; use `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` se preferir gerenciar Node assim. macOS, Linux e WSL2 são os caminhos principais. Windows nativo é suportado; veja [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) para os tropeços de configuração mais comuns.
Você não precisa de nenhum CLI de agente no `PATH` para desenvolver o próprio OD — o daemon dirá "no agents found" e cairá no caminho **Anthropic API · BYOK**, que é o loop de dev mais rápido de qualquer jeito. Você não precisa de nenhum CLI de agente no `PATH` para desenvolver o próprio OD — o daemon dirá "no agents found" e cairá no caminho **Anthropic API · BYOK**, que é o loop de dev mais rápido de qualquer jeito.
@ -243,6 +243,7 @@ Além disso:
- **Uma preocupação por PR.** Adicionar uma skill + refatorar o parser + bumpar uma dep são três PRs. - **Uma preocupação por PR.** Adicionar uma skill + refatorar o parser + bumpar uma dep são três PRs.
- **Título é imperativo + escopo.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`. - **Título é imperativo + escopo.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Use o template de PR.** Preencha cada seção de [`.github/pull_request_template.md`](.github/pull_request_template.md) — Why, What users will see, Surface area, Screenshots (se UI), Bug fix verification (se correção de bug), Validation. Seções vazias ganham um comentário "please fill in".
- **Corpo explica o porquê.** "O que isso faz" geralmente é óbvio do diff; "por que isso precisa existir" raramente é. - **Corpo explica o porquê.** "O que isso faz" geralmente é óbvio do diff; "por que isso precisa existir" raramente é.
- **Referencie uma issue** se houver. Se não houver e o PR for não-trivial, abra uma antes para combinarmos que a mudança é desejada antes de você gastar o tempo. - **Referencie uma issue** se houver. Se não houver e o PR for não-trivial, abra uma antes para combinarmos que a mudança é desejada antes de você gastar o tempo.
- **Sem squash durante review.** Empurre fixups; squash no merge. - **Sem squash durante review.** Empurre fixups; squash no merge.

View file

@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # 需要时构建 web package pnpm --filter @open-design/web build # 需要时构建 web package
``` ```
要求 Node `~24` 和 pnpm `10.33.x`。`nvm` / `fnm` 是可选路径;如果你习惯用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`。macOS、Linux、WSL2 是主要路径。Windows 原生应该能跑但不是主要目标 —— 跑不起来请开 issue 要求 Node `~24` 和 pnpm `10.33.x`。`nvm` / `fnm` 是可选路径;如果你习惯用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`。macOS、Linux、WSL2 是主要路径。Windows 原生已支持;常见的安装与配置坑请参见 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)
**开发 OD 本身不需要在 `PATH` 上装任何 agent CLI** —— daemon 会告诉你「找不到 agent」并落到 **Anthropic API · BYOK** 路径,反而是最快的开发循环。 **开发 OD 本身不需要在 `PATH` 上装任何 agent CLI** —— daemon 会告诉你「找不到 agent」并落到 **Anthropic API · BYOK** 路径,反而是最快的开发循环。
@ -234,6 +234,7 @@ node --experimental-strip-types scripts/sync-litellm-models.ts
- **一个 PR 只做一件事。** 加 skill + 重构 parser + 升依赖,是三个 PR。 - **一个 PR 只做一件事。** 加 skill + 重构 parser + 升依赖,是三个 PR。
- **标题用动词起头 + 范围。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。 - **标题用动词起头 + 范围。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。
- **使用 PR 模板。** 把 [`.github/pull_request_template.md`](.github/pull_request_template.md) 的每一节都填上 —— Why、What users will see、Surface area、Screenshots如果有 UI 改动、Bug fix verification如果是修 bug、Validation。留空的节会被 reviewer 回 "please fill in"。
- **正文解释 why。** 「这个 PR 改了什么」从 diff 一般能看出来;「为什么要改」很少能。 - **正文解释 why。** 「这个 PR 改了什么」从 diff 一般能看出来;「为什么要改」很少能。
- **如果有 issue引用它。** 没有、且改动非平凡,请先开 issue 让我们先就「值不值得做」达成一致,再投入时间。 - **如果有 issue引用它。** 没有、且改动非平凡,请先开 issue 让我们先就「值不值得做」达成一致,再投入时间。
- **Review 期间不要 squash。** 推 fixup commitmerge 时我们会 squash。 - **Review 期间不要 squash。** 推 fixup commitmerge 时我们会 squash。

View file

@ -55,8 +55,24 @@ docker compose version
From the repository root: From the repository root:
1. Change to the deploy directory and copy the environment template:
```bash
cd deploy
cp .env.example .env
```
2. Generate a secure token:
```bash
openssl rand -hex 32
```
3. Open `.env` in your editor, find `OD_API_TOKEN=`, and paste the generated token there.
Then start the service:
```bash ```bash
cd deploy
docker compose up -d docker compose up -d
``` ```
@ -107,7 +123,13 @@ docker compose down -v
## Environment Configuration ## Environment Configuration
Create a `deploy/.env` file to override the default configuration: Create a `deploy/.env` file to override the default configuration. Start from the provided example:
```bash
cp deploy/.env.example deploy/.env
```
Edit `deploy/.env` to set your own token and adjust other values as needed:
```env ```env
# Port exposed on the host # Port exposed on the host
@ -121,6 +143,10 @@ OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
# Docker image tag # Docker image tag
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
# Required API token for daemon security
# Generate one with: openssl rand -hex 32
OD_API_TOKEN=
``` ```
--- ---

View file

@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً. شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
</a> </a>
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول. إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen. Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
</a> </a>
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt. Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta. Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contribuidores de Open Design" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contribuidores de Open Design" />
</a> </a>
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada. Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte. Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contributeurs Open Design" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contributeurs Open Design" />
</a> </a>
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point dentrée. Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point dentrée.
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。 コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design コントリビューター" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design コントリビューター" />
</a> </a>
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。 初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -726,7 +726,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다. Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 컨트리뷰터" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 컨트리뷰터" />
</a> </a>
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다. 첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -1,6 +1,6 @@
# Open Design — the open-source Claude Design alternative # Open Design — the open-source Claude Design alternative
> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn. > **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **132 composable Skills** and **150 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
> [!IMPORTANT] > [!IMPORTANT]
> ### 🔥 `0.8.0-preview` is here. Design's old world ends here. > ### 🔥 `0.8.0-preview` is here. Design's old world ends here.
@ -30,8 +30,8 @@
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a> <a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a> <a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-149-orange?style=flat-square" /></a> <a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-150-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-131-teal?style=flat-square" /></a> <a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-132-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a> <a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="https://x.com/nexudotio"><img alt="Follow @nexudotio on X" src="https://img.shields.io/badge/follow-%40nexudotio-1DA1F2?style=flat-square&logo=x&logoColor=white" /></a> <a href="https://x.com/nexudotio"><img alt="Follow @nexudotio on X" src="https://img.shields.io/badge/follow-%40nexudotio-1DA1F2?style=flat-square&logo=x&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a> <a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
@ -65,8 +65,8 @@ OD stands on four open-source shoulders:
| **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click | | **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click |
| **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini / Ollama Cloud / SenseAudio, and the daemon normalizes SSE back to the same chat stream. SenseAudio chat additionally exposes `generate_image` and `generate_video` tools so the model can write rendered artifacts straight into the active project's folder. Internal-IP/SSRF blocked at the daemon edge. | | **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini / Ollama Cloud / SenseAudio, and the daemon normalizes SSE back to the same chat stream. SenseAudio chat additionally exposes `generate_image` and `generate_video` tools so the model can write rendered artifacts straight into the active project's folder. Internal-IP/SSRF blocked at the daemon edge. |
| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` | | **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` |
| **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. | | **Skills built-in** | **132** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. | | **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). Other image generators can already plug in through **Custom Image API** / **ImageRouter** when they expose an OpenAI-compatible image endpoint; workflow-first local runtimes such as **ComfyUI** are tracked separately as planned adapters. **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts)) | | **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter | | **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter |
@ -111,8 +111,8 @@ Linux AppImage packaging is available through the optional release lane and is c
<sub><b>Sandboxed preview</b> — every <code>&lt;artifact&gt;</code> renders in a clean srcdoc iframe. Editable in place via the file workspace; downloadable as HTML, PDF, ZIP.</sub> <sub><b>Sandboxed preview</b> — every <code>&lt;artifact&gt;</code> renders in a clean srcdoc iframe. Editable in place via the file workspace; downloadable as HTML, PDF, ZIP.</sub>
</td> </td>
<td width="50%"> <td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72-system library" /><br/> <img src="docs/screenshots/06-design-systems-library.png" alt="06 · 150-system library" /><br/>
<sub><b>72-system library</b> — every product system shows its 4-color signature. Click for the full <code>DESIGN.md</code>, swatch grid, and live showcase.</sub> <sub><b>150-system library</b> — every product system shows its 4-color signature. Click for the full <code>DESIGN.md</code>, swatch grid, and live showcase.</sub>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -129,9 +129,9 @@ Linux AppImage packaging is available through the optional release lane and is c
## Skills ## Skills
**31 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)). **132 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)).
Two top-level **modes** carry the catalog: **`prototype`** (27 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (4 skills — horizontal-swipe presentations with deck-framework chrome). The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`. Two **modes** anchor the interactive catalog: **`prototype`** (32 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (9 skills — horizontal-swipe presentations with deck-framework chrome). The catalog also ships `image`, `video`, `audio`, `template`, `design-system`, and `utility` modes for media generation, catalog updaters, and post-export audit helpers. The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Showcase examples ### Showcase examples
@ -259,8 +259,8 @@ What you compose at send time isn't "system + user". It's:
``` ```
DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critique) DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critique)
+ identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass) + identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
+ active DESIGN.md (72 systems available) + active DESIGN.md (150 systems available)
+ active SKILL.md (31 skills available) + active SKILL.md (132 skills available)
+ project metadata (kind, fidelity, speakerNotes, animations, inspiration ids) + project metadata (kind, fidelity, speakerNotes, animations, inspiration ids)
+ skill side files (auto-injected pre-flight: read assets/template.html + references/*.md) + skill side files (auto-injected pre-flight: read assets/template.html + references/*.md)
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
@ -338,9 +338,25 @@ docker compose version
#### Start Open Design #### Start Open Design
```bash id="m9w43w" 1. Clone the repository, change to the deploy directory, and copy the environment template:
git clone https://github.com/nexu-io/open-design.git
cd open-design/deploy ```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design/deploy
cp .env.example .env
```
2. Generate a secure token:
```bash
openssl rand -hex 32
```
3. Open `.env` in your editor, find `OD_API_TOKEN=`, and paste the generated token there.
Then start the service:
```bash
docker compose up -d docker compose up -d
``` ```
@ -392,7 +408,7 @@ For desktop/background startup, fixed-port restarts, and media generation dispat
The first load: The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically. 1. Detects which agent CLIs you have on `PATH` and picks one automatically.
2. Loads 31 skills + 72 design systems. 2. Loads 132 skills + 150 design systems.
3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path). 3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path).
4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot. 4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot.
@ -693,7 +709,7 @@ open-design/
│ ├── sidecar/ ← generic sidecar runtime primitives │ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives │ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck) ├── skills/ ← 132 SKILL.md skill bundles (32 prototype + 9 deck + image / video / audio / template / design-system / utility)
│ ├── web-prototype/ ← default for prototype mode │ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/ │ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/ │ ├── mobile-app/ mobile-onboarding/ gamified-app/
@ -708,7 +724,7 @@ open-design/
│ ├── assets/template.html ← seed │ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md │ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems ├── design-systems/ ← 150 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter) │ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter) │ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ … │ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
@ -750,10 +766,10 @@ open-design/
## Design Systems ## Design Systems
<p align="center"> <p align="center">
<img src="docs/assets/design-systems-library.png" alt="The 72 design systems library — style guide spread" width="100%" /> <img src="docs/assets/design-systems-library.png" alt="The 150 design systems library — style guide spread" width="100%" />
</p> </p>
72 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md): 150 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md):
<details> <details>
<summary><b>Full catalog</b> (click to expand)</summary> <summary><b>Full catalog</b> (click to expand)</summary>
@ -798,7 +814,7 @@ Full spec → [`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/
OD doesn't stop at code. The same chat surface that produces `<artifact>` HTML also drives **image**, **video**, and **audio** generation, with model adapters wired into the daemon's media pipeline ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Every render lands as a real file in the project workspace — `.png` for image, `.mp4` for video — and shows up as a download chip when the turn ends. OD doesn't stop at code. The same chat surface that produces `<artifact>` HTML also drives **image**, **video**, and **audio** generation, with model adapters wired into the daemon's media pipeline ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Every render lands as a real file in the project workspace — `.png` for image, `.mp4` for video — and shows up as a download chip when the turn ends.
Three model families carry the load today: Three flagship paths carry the load today:
| Surface | Model | Provider | What it's for | | Surface | Model | Provider | What it's for |
|---|---|---|---| |---|---|---|---|
@ -806,6 +822,12 @@ Three model families carry the load today:
| **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v with audio — narrative shorts, character close-ups, product films, MV-style choreography | | **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v with audio — narrative shorts, character close-ups, product films, MV-style choreography |
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — product reveals, kinetic typography, data charts, social overlays, logo outros, TikTok-style verticals with karaoke captions | | **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — product reveals, kinetic typography, data charts, social overlays, logo outros, TikTok-style verticals with karaoke captions |
Other generators are possible, but the path depends on the API shape:
- **Today:** use **Settings → Media providers → Custom Image API** for any local or hosted image generator that exposes an OpenAI-compatible `POST /v1/images/generations` endpoint. **ImageRouter** covers the same contract for routed image and video backends.
- **Not wired yet:** workflow-first local runtimes such as **ComfyUI**. OD now lists ComfyUI in the Media providers “Coming soon” drawer to make that gap explicit, but direct JSON-workflow execution still needs a dedicated adapter.
- **Still provider-specific:** arbitrary non-OpenAI-compatible video APIs. Those need a first-class daemon integration rather than just a base URL swap.
A growing **prompt gallery** at [`prompt-templates/`](prompt-templates/) ships **93 ready-to-replicate prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluding `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Each carries a preview thumbnail, the prompt body verbatim, the target model, the aspect ratio, and a `source` block for license + attribution. The daemon serves them at `GET /api/prompt-templates`, the web app surfaces them as a card grid in the **Image templates** and **Video templates** tabs of the entry view; one click drops a prompt into the composer with the right model preselected. A growing **prompt gallery** at [`prompt-templates/`](prompt-templates/) ships **93 ready-to-replicate prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluding `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Each carries a preview thumbnail, the prompt body verbatim, the target model, the aspect ratio, and a `source` block for license + attribution. The daemon serves them at `GET /api/prompt-templates`, the web app surfaces them as a card grid in the **Image templates** and **Video templates** tabs of the entry view; one click drops a prompt into the composer with the right model preselected.
### gpt-image-2 — image gallery (sample of 43) ### gpt-image-2 — image gallery (sample of 43)
@ -873,7 +895,7 @@ The chat / artifact loop gets the spotlight, but a handful of less-visible capab
- **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`) - **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`. - **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`.
- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 31, but yours. - **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 132, but yours.
- **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it. - **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it.
- **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes. - **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes.
- **Sidecar protocol + desktop automation.** Daemon, web, and desktop processes carry typed five-field stamps (`app · mode · namespace · ipc · source`) and expose a JSON-RPC IPC channel at `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` drives that channel, so headless E2E works against a real Electron shell without bespoke harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)). - **Sidecar protocol + desktop automation.** Daemon, web, and desktop processes carry typed five-field stamps (`app · mode · namespace · ipc · source`) and expose a JSON-RPC IPC channel at `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` drives that channel, so headless E2E works against a real Electron shell without bespoke harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
@ -899,7 +921,7 @@ The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf
| Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** | | Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** |
| Deployable on Vercel | ❌ | ❌ | **✅** | | Deployable on Vercel | ❌ | ❌ | **✅** |
| Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** | | Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** |
| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **31 file-based [`SKILL.md`][skill] bundles, droppable** | | Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **132 file-based [`SKILL.md`][skill] bundles, droppable** |
| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 systems shipped** | | Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 systems shipped** |
| Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI adapters + OpenAI-compatible BYOK proxy** | | Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI adapters + OpenAI-compatible BYOK proxy** |
| Init question form | ❌ | ❌ | **✅ Hard rule, turn 1** | | Init question form | ❌ | ❌ | **✅ Hard rule, turn 1** |
@ -972,7 +994,7 @@ Long-form provenance write-up — what we take from each, what we deliberately d
- [x] Daemon + agent detection (16 CLI adapters) + skill registry + design-system catalog - [x] Daemon + agent detection (16 CLI adapters) + skill registry + design-system catalog
- [x] Web app + chat + question form + 5-direction picker + todo progress + sandboxed preview - [x] Web app + chat + question form + 5-direction picker + todo progress + sandboxed preview
- [x] 31 skills + 72 design systems + 5 visual directions + 5 device frames - [x] 132 skills + 150 design systems + 5 visual directions + 5 device frames
- [x] SQLite-backed projects · conversations · messages · tabs · templates - [x] SQLite-backed projects · conversations · messages · tabs · templates
- [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream`) with SSRF guard - [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream`) with SSRF guard
- [x] Claude Design ZIP import (`/api/import/claude-design`) - [x] Claude Design ZIP import (`/api/import/claude-design`)
@ -1018,7 +1040,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud. Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
</a> </a>
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point. If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
@ -1035,9 +1057,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta. Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contribuidoras e contribuidores do Open Design" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contribuidoras e contribuidores do Open Design" />
</a> </a>
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada. Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух. Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contributors Open Design" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contributors Open Design" />
</a> </a>
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа. Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu. Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
</a> </a>
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır. İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос. Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Контриб'ютори Open Design" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Контриб'ютори Open Design" />
</a> </a>
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу. Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。 感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 贡献者" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 贡献者" />
</a> </a>
第一次提 PR欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。 第一次提 PR欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -713,7 +713,8 @@ open-design/
│ └── browser-chrome.html │ └── browser-chrome.html
├── templates/ ├── templates/
│ └── deck-framework.html ← deck 基線nav / counter / print │ ├── deck-framework.html ← deck 基線nav / counter / print
│ └── kami-deck.html ← kami 風格 deck 起手(羊皮紙 / 墨藍襯線)
├── scripts/ ├── scripts/
│ └── sync-design-systems.ts ← 從上游 awesome-design-md tarball 重新匯入 │ └── sync-design-systems.ts ← 從上游 awesome-design-md tarball 重新匯入
@ -888,7 +889,7 @@ Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得
| 可部署 Vercel | ❌ | ❌ | **✅** | | 可部署 Vercel | ❌ | ❌ | **✅** |
| Agent 執行時 | 內建 (Opus 4.7) | 內建 ([`pi-ai`][piai]) | **委託給使用者已裝好的 CLI** | | Agent 執行時 | 內建 (Opus 4.7) | 內建 ([`pi-ai`][piai]) | **委託給使用者已裝好的 CLI** |
| Skill | 私有 | 12 套自定義 TS 模組 + `SKILL.md` | **31 套基於檔案的 [`SKILL.md`][skill],可丟入** | | Skill | 私有 | 12 套自定義 TS 模組 + `SKILL.md` | **31 套基於檔案的 [`SKILL.md`][skill],可丟入** |
| Design system | 私有 | `DESIGN.md`v0.2 路線圖) | **`DESIGN.md` × 72 套,開箱即有** | | Design system | 私有 | `DESIGN.md`v0.2 路線圖) | **`DESIGN.md` × 129 套,開箱即有** |
| Provider 靈活度 | 僅 Anthropic | 7+[`pi-ai`][piai] | **16 套 CLI adapter + OpenAI 相容 BYOK 代理** | | Provider 靈活度 | 僅 Anthropic | 7+[`pi-ai`][piai] | **16 套 CLI adapter + OpenAI 相容 BYOK 代理** |
| 初始化問題表單 | ❌ | ❌ | **✅ 硬規則 turn 1** | | 初始化問題表單 | ❌ | ❌ | **✅ 硬規則 turn 1** |
| 方向選擇器 | ❌ | ❌ | **✅ 5 套確定性方向** | | 方向選擇器 | ❌ | ❌ | **✅ 5 套確定性方向** |
@ -1005,7 +1006,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。 感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors"> <a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 貢獻者" /> <img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 貢獻者" />
</a> </a>
第一次提 PR歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。 第一次提 PR歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
@ -1022,9 +1023,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
<a href="https://star-history.com/#nexu-io/open-design&Date"> <a href="https://star-history.com/#nexu-io/open-design&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" /> <img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
</picture> </picture>
</a> </a>

View file

@ -104,7 +104,7 @@ pnpm i18n:check
## 📋 Supported Languages ## 📋 Supported Languages
Open Design currently supports **18 languages** across different surfaces: Open Design currently supports **19 languages** across different surfaces:
| Language | Code | README | UI Dict | Core Docs | Status | | Language | Code | README | UI Dict | Core Docs | Status |
| -------------------- | ------- | ------ | ------- | --------- | ------ | | -------------------- | ------- | ------ | ------- | --------- | ------ |
@ -116,6 +116,7 @@ Open Design currently supports **18 languages** across different surfaces:
| Français | `fr` | ✅ | ✅ | ✅ | active | | Français | `fr` | ✅ | ✅ | ✅ | active |
| Magyar (Hungarian) | `hu` | — | ✅ | — | active | | Magyar (Hungarian) | `hu` | — | ✅ | — | active |
| Bahasa Indonesia | `id` | — | ✅ | — | active | | Bahasa Indonesia | `id` | — | ✅ | — | active |
| Italiano | `it` | — | ✅ | — | active |
| 日本語 (Japanese) | `ja` | ✅ | ✅ | ✅ | active | | 日本語 (Japanese) | `ja` | ✅ | ✅ | ✅ | active |
| 한국어 (Korean) | `ko` | ✅ | ✅ | — | active | | 한국어 (Korean) | `ko` | ✅ | ✅ | — | active |
| Polski (Polish) | `pl` | — | ✅ | — | active | | Polski (Polish) | `pl` | — | ✅ | — | active |
@ -331,6 +332,88 @@ Translations follow the conventions of the target region's tech writing communit
- "quickstart" → your language's equivalent - "quickstart" → your language's equivalent
- "settings" → your language's equivalent - "settings" → your language's equivalent
### French (`fr`) Glossary
French UI copy should read naturally for a technical product audience without
turning product/runtime terms into vague French approximations. Keep these
rules stable across `apps/web/src/i18n/locales/fr.ts`, French core docs, and
French display metadata.
#### Keep in English
Keep the exact English/token form for names, protocols, commands, environment
variables, code identifiers, package names, file extensions, and technical
runtime nouns that are clearer in English:
| English source | French usage |
| -------------- | ------------ |
| Open Design | Open Design |
| Claude Code, Codex, Cursor, Gemini, OpenCode | Claude Code, Codex, Cursor, Gemini, OpenCode |
| CLI, API, SDK, MCP, HTTP, REST, SSE, JSONL | CLI, API, SDK, MCP, HTTP, REST, SSE, JSONL |
| BYOK | BYOK |
| runtime | runtime |
| daemon | daemon |
| sidecar | sidecar |
| headless | headless |
| plugin | plugin |
| prompt | prompt |
| token | token |
| iframe | iframe |
| monorepo, workspace | monorepo, workspace |
| `od`, `pnpm`, `pnpm tools-dev` | `od`, `pnpm`, `pnpm tools-dev` |
| `OD_DATA_DIR`, `OD_WEB_PORT`, `{provider}` | `OD_DATA_DIR`, `OD_WEB_PORT`, `{provider}` |
| `.zip`, `.html`, `.md`, `.json` | `.zip`, `.html`, `.md`, `.json` |
Use French grammar around preserved terms:
- `le daemon local`, `un runtime`, `des plugins`, `les prompts`
- `lAPI`, `un endpoint REST`, `un flux SSE`
- `la CLI locale`, `un serveur MCP`
#### Translate When Standard
Translate ordinary UI terms, workflow labels, and non-identifier product copy
when a natural French equivalent exists:
| English source | French |
| -------------- | ------ |
| Settings | Paramètres |
| Save | Enregistrer |
| Cancel | Annuler |
| Delete | Supprimer |
| Folder | Dossier |
| File | Fichier |
| Download | Télécharger |
| Upload | Téléverser |
| Search | Rechercher |
| Preview | Aperçu |
| Project | Projet |
| Conversation | Conversation |
| Dashboard | Tableau de bord |
| Schedule | Planification |
| Automation | Automatisation |
| Artifact | Artefact |
| Live artifact | Artefact dynamique |
| Design files | Fichiers de design |
| Slide deck | Présentation |
| Engineering handoff | Transmission aux ingénieurs |
| Shipped (product/software status) | Livré |
#### Context-Sensitive Choices
- `Skill` stays `Skill` when it names the Open Design/Claude skill format.
Translate only generic prose such as "ability" or "capability" as
`capacité`.
- `Design System` may stay `Design System` when referring to the product
registry/object name. In explanatory prose, `système de design` is also
acceptable when it improves readability.
- `runtime` stays `runtime` as a noun. Labels like "execution mode" can still
use `mode dexécution`.
- `source` can stay `source` for provenance labels, but translate ordinary
"data source" as `source de données`.
- Do not translate command output or examples that users should see exactly in
their terminal.
### zh-CN ↔ zh-TW Glossary ### zh-CN ↔ zh-TW Glossary
When converting between Simplified and Traditional Chinese, prefer Taiwan-specific phrasing in zh-TW rather than character-only conversion. This list grew out of [PR #194](https://github.com/nexu-io/open-design/pull/194) and is meant as a starting point, not a rulebook. When converting between Simplified and Traditional Chinese, prefer Taiwan-specific phrasing in zh-TW rather than character-only conversion. This list grew out of [PR #194](https://github.com/nexu-io/open-design/pull/194) and is meant as a starting point, not a rulebook.

View file

@ -1,6 +1,6 @@
{ {
"name": "@open-design/daemon", "name": "@open-design/daemon",
"version": "0.7.0", "version": "0.8.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/cli.js", "main": "./dist/cli.js",
@ -44,8 +44,9 @@
"@opentelemetry/api": "1.9.1", "@opentelemetry/api": "1.9.1",
"better-sqlite3": "12.10.0", "better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5", "blake3-wasm": "2.1.5",
"cheerio": "1.2.0",
"chokidar": "5.0.0", "chokidar": "5.0.0",
"express": "4.22.1", "express": "5.2.1",
"jszip": "3.10.1", "jszip": "3.10.1",
"multer": "2.1.1", "multer": "2.1.1",
"posthog-node": "5.34.6", "posthog-node": "5.34.6",
@ -55,7 +56,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/express": "4.17.25", "@types/express": "5.0.6",
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "20.19.39", "@types/node": "20.19.39",
"typescript": "5.9.3", "typescript": "5.9.3",

View file

@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Ad-hoc end-to-end verifier: drives the real `vela` binary through Open
* Design's `attachAcpSession`. Not part of the test suite it makes a real
* OpenRouter request when VELA_RUNTIME_KEY is a live key.
*
* Usage:
* VELA_BIN=/path/to/vela \
* VELA_RUNTIME_KEY=<openrouter-key> \
* VELA_LINK_URL=https://openrouter.ai/api/v1 \
* PATH=<dir-with-opencode>:$PATH \
* node apps/daemon/scripts/verify-amr-real-vela.mjs
*
* Behaviour:
* - Runs initialize session/new session/set_model (if --model given)
* session/prompt with the prompt from VELA_VERIFY_PROMPT (defaults to a
* short hello).
* - Logs every Open Design `send(event, payload)` to stdout so you can see
* the same text_delta / usage events the chat UI would receive.
* - Exits 0 on completedSuccessfully, 1 otherwise.
*/
import { spawn } from 'node:child_process';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const { attachAcpSession } = await import(
path.join(HERE, '..', 'dist', 'acp.js')
);
const velaBin = process.env.VELA_BIN || 'vela';
const prompt = process.env.VELA_VERIFY_PROMPT || 'Reply with the exact text: AMR-E2E-OK.';
const model = process.env.VELA_VERIFY_MODEL || null;
if (
(!process.env.VELA_RUNTIME_KEY || !process.env.VELA_LINK_URL) &&
!process.env.VELA_PROFILE
) {
console.error(
'Provide credentials via either:\n' +
' - VELA_RUNTIME_KEY + VELA_LINK_URL env vars, or\n' +
' - VELA_PROFILE (e.g. "local") with a logged-in ~/.amr/config.json.',
);
process.exit(2);
}
const child = spawn(velaBin, ['agent', 'run', '--runtime', 'opencode'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
});
child.stderr.on('data', (chunk) => {
process.stderr.write(`[vela.stderr] ${chunk}`);
});
child.on('error', (err) => {
console.error('[child.error]', err.message);
});
child.on('exit', (code, signal) => {
console.error(`[child.exit] code=${code} signal=${signal}`);
});
child.on('close', (code, signal) => {
console.error(`[child.close] code=${code} signal=${signal}`);
});
const overallTimeoutMs = Number(process.env.VELA_VERIFY_TIMEOUT_MS) || 120_000;
const overallTimer = setTimeout(() => {
console.error(`[verify-amr] overall timeout after ${overallTimeoutMs}ms; SIGTERM child`);
if (!child.killed) child.kill('SIGTERM');
}, overallTimeoutMs);
overallTimer.unref?.();
const session = attachAcpSession({
child,
prompt,
cwd: process.cwd(),
model,
mcpServers: [],
send: (event, payload) => {
const stamp = new Date().toISOString();
if (event === 'agent' && payload?.type === 'text_delta') {
process.stdout.write(payload.delta);
return;
}
console.log(`\n[${stamp}] ${event} ${JSON.stringify(payload)}`);
},
});
await new Promise((resolve) => child.on('close', resolve));
process.stdout.write('\n');
if (session.hasFatalError()) {
console.error('Session reported fatal error.');
process.exit(1);
}
if (!session.completedSuccessfully()) {
console.error('Session did not complete successfully.');
process.exit(1);
}
console.log('verify-amr-real-vela: OK');

View file

@ -65,11 +65,13 @@ interface AttachAcpSessionOptions {
prompt: string; prompt: string;
cwd?: string; cwd?: string;
model?: string | null; model?: string | null;
imagePaths?: string[];
mcpServers?: AcpMcpServerInput[]; mcpServers?: AcpMcpServerInput[];
send: (event: string, payload: unknown) => void; send: (event: string, payload: unknown) => void;
clientName?: string; clientName?: string;
clientVersion?: string; clientVersion?: string;
stageTimeoutMs?: number; stageTimeoutMs?: number;
modelUnavailableErrorCode?: 'AMR_MODEL_UNAVAILABLE';
} }
function errorMessage(err: unknown): string { function errorMessage(err: unknown): string {
@ -115,6 +117,15 @@ function sendRpcResult(writable: RpcWritable, id: JsonRpcId, result: unknown): v
writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
} }
function buildPromptBlocks(prompt: string, imagePaths: string[]): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [{ type: 'text', text: prompt }];
for (const imagePath of imagePaths) {
if (typeof imagePath !== 'string' || imagePath.trim().length === 0) continue;
blocks.push({ type: 'resource_link', uri: imagePath });
}
return blocks;
}
function isJsonRpcId(value: unknown): value is JsonRpcId { function isJsonRpcId(value: unknown): value is JsonRpcId {
return typeof value === 'number' || typeof value === 'string'; return typeof value === 'number' || typeof value === 'string';
} }
@ -421,11 +432,13 @@ export function attachAcpSession({
prompt, prompt,
cwd, cwd,
model, model,
imagePaths = [],
mcpServers, mcpServers,
send, send,
clientName = 'open-design', clientName = 'open-design',
clientVersion = 'runtime-adapter', clientVersion = 'runtime-adapter',
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS, stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
modelUnavailableErrorCode,
}: AttachAcpSessionOptions) { }: AttachAcpSessionOptions) {
const runStartedAt = Date.now(); const runStartedAt = Date.now();
const effectiveCwd = path.resolve(cwd || process.cwd()); const effectiveCwd = path.resolve(cwd || process.cwd());
@ -443,6 +456,7 @@ export function attachAcpSession({
let modelConfigId: string | null = null; let modelConfigId: string | null = null;
let emittedThinkingStart = false; let emittedThinkingStart = false;
let emittedFirstTokenStatus = false; let emittedFirstTokenStatus = false;
let emittedTextChunk = false;
let finished = false; let finished = false;
let fatal = false; let fatal = false;
let aborted = false; let aborted = false;
@ -467,12 +481,41 @@ export function attachAcpSession({
stageTimer = null; stageTimer = null;
}; };
const fail = (message: string) => { const amrModelUnavailablePayload = (message: string) => ({
message,
error: {
code: 'AMR_MODEL_UNAVAILABLE',
message,
retryable: false,
details: { kind: 'amr_model', action: 'choose_model' },
},
});
const isModelUnavailableError = (message: string) => {
const value = message.toLowerCase();
return (
value.includes('model not found') ||
value.includes('providermodelnotfounderror') ||
value.includes('unknown model') ||
value.includes('invalid model')
);
};
const fail = (
message: string,
options: { forceModelUnavailable?: boolean } = {},
) => {
if (finished) return; if (finished) return;
finished = true; finished = true;
fatal = true; fatal = true;
clearStageTimer(); clearStageTimer();
send('error', { message }); const useModelUnavailable =
modelUnavailableErrorCode &&
(options.forceModelUnavailable || isModelUnavailableError(message));
send(
'error',
useModelUnavailable ? amrModelUnavailablePayload(message) : { message },
);
if (!child.killed) child.kill('SIGTERM'); if (!child.killed) child.kill('SIGTERM');
}; };
@ -493,7 +536,7 @@ export function attachAcpSession({
'session/prompt', 'session/prompt',
{ {
sessionId, sessionId,
prompt: [{ type: 'text', text: prompt }], prompt: buildPromptBlocks(prompt, imagePaths),
}, },
'session/prompt', 'session/prompt',
); );
@ -575,6 +618,7 @@ export function attachAcpSession({
if (update.sessionUpdate === 'agent_message_chunk') { if (update.sessionUpdate === 'agent_message_chunk') {
const text = asObject(update.content)?.text; const text = asObject(update.content)?.text;
if (typeof text === 'string' && text.length > 0) { if (typeof text === 'string' && text.length > 0) {
emittedTextChunk = true;
if (!emittedFirstTokenStatus) { if (!emittedFirstTokenStatus) {
emittedFirstTokenStatus = true; emittedFirstTokenStatus = true;
send('agent', { send('agent', {
@ -638,6 +682,13 @@ export function attachAcpSession({
return; return;
} }
if (promptRequestId !== null && obj.id === promptRequestId) { if (promptRequestId !== null && obj.id === promptRequestId) {
if (!emittedTextChunk && modelUnavailableErrorCode) {
fail(
'ACP session completed without producing any assistant text. Refresh the AMR model list, choose a supported model, and retry this run.',
{ forceModelUnavailable: true },
);
return;
}
const usage = formatUsage(result.usage); const usage = formatUsage(result.usage);
if (usage) { if (usage) {
send('agent', { send('agent', {
@ -672,9 +723,12 @@ export function attachAcpSession({
}); });
stdout.on('data', (chunk: string) => parser.feed(chunk)); stdout.on('data', (chunk: string) => parser.feed(chunk));
child.on('close', () => { child.on('close', (code, signal) => {
clearStageTimer(); clearStageTimer();
parser.flush(); parser.flush();
if (!finished && !aborted && !fatal) {
fail(`ACP session exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
}
}); });
child.on('error', (err: Error) => fail(err.message)); child.on('error', (err: Error) => fail(err.message));
stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`)); stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`));
@ -701,12 +755,27 @@ export function attachAcpSession({
aborted = true; aborted = true;
finished = true; finished = true;
clearStageTimer(); clearStageTimer();
if (!sessionId || !child.stdin || child.stdin.destroyed || child.stdin.writableEnded) return; if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded)
return;
// Only cancel an established session; before session/new resolves there
// is no sessionId to cancel, but we must still close stdin below.
if (sessionId) {
try {
sendRpc(child.stdin, nextId, 'session/cancel', { sessionId });
nextId += 1;
} catch {
// The caller owns process-signal fallback if the ACP transport is gone.
}
}
// Always close stdin so the agent receives EOF and shuts down its own
// runtime — the vela ACP bridge tears down its private OpenCode server on
// EOF — instead of lingering (and leaking that server) until the caller's
// SIGTERM fallback fires. This also covers aborts during ACP startup,
// before session/new returns. Mirrors the clean-completion path above.
try { try {
sendRpc(child.stdin, nextId, 'session/cancel', { sessionId }); child.stdin.end();
nextId += 1;
} catch { } catch {
// The caller owns process-signal fallback if the ACP transport is gone. // Best effort; the caller still owns the SIGTERM/SIGKILL fallback.
} }
}, },
}; };

View file

@ -1,71 +1,128 @@
import type { Express } from 'express'; import type { Express } from 'express';
import { createApiError } from '@open-design/contracts';
import { ACTIVE_CONTEXT_TTL_MS } from './constants.js'; import { ACTIVE_CONTEXT_TTL_MS } from './constants.js';
import type { RouteDeps } from './server-context.js'; import type { RouteDeps } from './server-context.js';
import { defineJsonRoute, err, mountJsonRoute, ok, type Result } from './http/index.js';
export interface RegisterActiveContextRoutesDeps extends RouteDeps<'db' | 'http' | 'projectStore'> {} export interface RegisterActiveContextRoutesDeps extends RouteDeps<'db' | 'http' | 'projectStore'> {}
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps) { // Soft "what is the user looking at right now in Open Design?" channel. The
const { db } = ctx; // web UI POSTs the current project + file on every route change; the MCP
const { sendApiError, isLocalSameOrigin, resolvedPortRef } = ctx.http; // surface reads it so a coding agent in another repo can resolve "the design
const { getProject } = ctx.projectStore; // I have open" without the user typing the project id. In-memory only —
const getResolvedPort = () => resolvedPortRef.current; // daemon restart clears it.
interface ActiveContext {
projectId: string;
fileName: string | null;
ts: number;
}
// Soft "what is the user looking at right now in Open Design?" channel. The interface ActiveContextStore {
// web UI POSTs the current project + file on every route change; the MCP current: ActiveContext | null;
// surface reads it so a coding agent in another repo can resolve "the design }
// I have open" without the user typing the project id. In-memory only -
// daemon restart clears it.
let activeContext: { projectId: string; fileName: string | null; ts: number } | null = null;
// Active context is private to the local machine. The daemon may bind beyond type PostActiveInput =
// loopback, so without an origin check a peer on the LAN could read what the | { kind: 'clear' }
// user is currently looking at (GET) or spoof it to redirect MCP fallbacks | { kind: 'set'; projectId: string; fileName: string | null };
// (POST). The web proxies same-origin and MCP runs in-process via 127.0.0.1,
// so both legitimate callers pass the check.
app.post('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const body = req.body || {};
if (body.active === false) {
activeContext = null;
res.json({ active: false });
return;
}
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
if (!projectId) {
sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required');
return;
}
const fileName =
typeof body.fileName === 'string' && body.fileName.length > 0
? body.fileName
: null;
activeContext = { projectId, fileName, ts: Date.now() };
res.json({ active: true, ...activeContext });
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/active', (req, res) => { type PostActiveOutput =
if (!isLocalSameOrigin(req, getResolvedPort())) { | { active: false }
return res.status(403).json({ error: 'cross-origin request rejected' }); | { active: true; projectId: string; fileName: string | null; ts: number };
}
if (!activeContext || Date.now() - activeContext.ts > ACTIVE_CONTEXT_TTL_MS) { type GetActiveOutput =
activeContext = null; | { active: false }
res.json({ active: false }); | {
return; active: true;
} projectId: string;
const project = getProject(db, activeContext.projectId); projectName: string | null;
res.json({ fileName: string | null;
active: true, ts: number;
projectId: activeContext.projectId, ageMs: number;
projectName: project?.name ?? null, };
fileName: activeContext.fileName,
ts: activeContext.ts, interface ActiveContextDomainDeps {
ageMs: Date.now() - activeContext.ts, store: ActiveContextStore;
}); db: unknown;
getProject: (db: unknown, projectId: string) => { name?: string | null } | null | undefined;
now: () => number;
}
function parsePostActive(raw: { body: unknown }): Result<PostActiveInput> {
const body = (raw.body ?? {}) as Record<string, unknown>;
if (body.active === false) {
return ok({ kind: 'clear' });
}
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
if (!projectId) {
return err(createApiError('BAD_REQUEST', 'projectId is required'));
}
const fileName =
typeof body.fileName === 'string' && body.fileName.length > 0 ? body.fileName : null;
return ok({ kind: 'set', projectId, fileName });
}
function handlePostActive(
input: PostActiveInput,
deps: ActiveContextDomainDeps,
): Result<PostActiveOutput> {
if (input.kind === 'clear') {
deps.store.current = null;
return ok({ active: false });
}
const next: ActiveContext = {
projectId: input.projectId,
fileName: input.fileName,
ts: deps.now(),
};
deps.store.current = next;
return ok({ active: true, ...next });
}
function handleGetActive(
_input: void,
deps: ActiveContextDomainDeps,
): Result<GetActiveOutput> {
const current = deps.store.current;
if (!current || deps.now() - current.ts > ACTIVE_CONTEXT_TTL_MS) {
deps.store.current = null;
return ok({ active: false });
}
const project = deps.getProject(deps.db, current.projectId);
return ok({
active: true,
projectId: current.projectId,
projectName: project?.name ?? null,
fileName: current.fileName,
ts: current.ts,
ageMs: deps.now() - current.ts,
}); });
} }
export const postActiveRoute = defineJsonRoute<PostActiveInput, PostActiveOutput, ActiveContextDomainDeps>({
method: 'post',
path: '/api/active',
requireSameOrigin: true,
parse: parsePostActive,
handle: handlePostActive,
});
export const getActiveRoute = defineJsonRoute<void, GetActiveOutput, ActiveContextDomainDeps>({
method: 'get',
path: '/api/active',
requireSameOrigin: true,
parse: () => ok(undefined),
handle: handleGetActive,
});
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps): void {
const store: ActiveContextStore = { current: null };
const domainDeps: ActiveContextDomainDeps = {
store,
db: ctx.db,
getProject: ctx.projectStore.getProject,
now: () => Date.now(),
};
const adapter = { resolvedPortRef: ctx.http.resolvedPortRef };
mountJsonRoute(app, postActiveRoute, domainDeps, adapter);
mountJsonRoute(app, getActiveRoute, domainDeps, adapter);
}

View file

@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const STAGING_DIRNAME = '.amr-attachments';
const STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function isWithinRoot(root: string, candidate: string): boolean {
const relativePath = path.relative(root, candidate);
return (
relativePath === '' ||
(relativePath.length > 0 &&
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath))
);
}
async function pruneStagedAttachments(stagingDir: string): Promise<void> {
let entries;
try {
entries = await fs.promises.readdir(stagingDir, { withFileTypes: true });
} catch {
return;
}
const cutoff = Date.now() - STAGING_MAX_AGE_MS;
await Promise.all(entries.map(async (entry) => {
if (!entry.isFile()) return;
const filePath = path.join(stagingDir, entry.name);
try {
const stat = await fs.promises.stat(filePath);
if (stat.mtimeMs < cutoff) {
await fs.promises.rm(filePath, { force: true });
}
} catch {
// Best-effort cleanup only.
}
}));
}
export async function stageAmrImagePaths(
cwd: string | null | undefined,
imagePaths: string[],
uploadRoot?: string | null,
): Promise<string[]> {
if (!cwd || !Array.isArray(imagePaths) || imagePaths.length === 0) return [];
const root = path.resolve(cwd);
const uploadRootReal = uploadRoot
? await fs.promises.realpath(uploadRoot).catch(() => null)
: null;
const stagingDir = path.join(root, STAGING_DIRNAME);
await fs.promises.mkdir(stagingDir, { recursive: true });
await pruneStagedAttachments(stagingDir);
const staged: string[] = [];
for (const inputPath of imagePaths) {
if (typeof inputPath !== 'string' || inputPath.trim().length === 0) continue;
try {
const resolved = path.resolve(inputPath);
const real = await fs.promises.realpath(resolved);
if (uploadRootReal && !isWithinRoot(uploadRootReal, real)) continue;
const stat = await fs.promises.stat(real);
if (!stat.isFile()) continue;
if (isWithinRoot(root, real)) {
staged.push(real);
continue;
}
const basename = path.basename(real);
const destination = path.join(stagingDir, `${randomUUID()}-${basename}`);
await fs.promises.copyFile(real, destination);
staged.push(destination);
} catch {
// Ignore malformed or missing files; attachments are advisory input.
}
}
return staged;
}

View file

@ -90,11 +90,35 @@ export interface AnalyticsService {
properties: Record<string, unknown>; properties: Record<string, unknown>;
insertId: string; insertId: string;
}): void; }): void;
/**
* Safety / reliability events (renderer crashes, daemon uncaught errors,
* SSE health, etc.) that intentionally BYPASS the user's analytics
* consent toggle. The product policy is: we always retain ground-truth
* stability data even for opted-out users the user-facing consent copy
* in Settings Privacy must call this out.
*
* Falls back to a synthetic distinctId when the installationId is not
* yet stamped (first-launch or fork builds without an app-config file).
*
* Returns a Promise that resolves AFTER the event has been enqueued in
* posthog-node's local buffer. Fire-and-forget callers (e.g. the
* /api/observability/event endpoint) can `void` it; fatal-exit paths
* MUST await before calling `shutdown()` so the crash event actually
* makes it into the flush.
*/
captureSafety(args: {
eventName: string;
distinctId?: string;
appVersion: string;
properties: Record<string, unknown>;
insertId?: string;
}): Promise<void>;
shutdown(): Promise<void>; shutdown(): Promise<void>;
} }
const NOOP_SERVICE: AnalyticsService = { const NOOP_SERVICE: AnalyticsService = {
capture: () => undefined, capture: () => undefined,
captureSafety: async () => undefined,
shutdown: async () => undefined, shutdown: async () => undefined,
}; };
@ -165,6 +189,44 @@ export function createAnalyticsService(args: {
} }
})(); })();
}, },
captureSafety: async ({ eventName, distinctId, appVersion, properties, insertId }) => {
// No consent re-check here — that's the entire point of this surface.
// We still fall back gracefully when the installationId is missing
// (cold start before the daemon has stamped one in app-config) by
// synthesizing an anonymous distinct id rooted at the process boot.
//
// Returns a Promise that resolves AFTER `client.capture()` has run.
// The fatal-shutdown path in server.ts awaits this before invoking
// `shutdown()` so the event is guaranteed to be in posthog-node's
// local queue when the flush starts — otherwise a fast `shutdown()`
// would drain an empty queue and the crash signal would be lost.
// See codex review on PR #2527 (Siri-Ray) for the original race.
const resolvedInsertId = insertId ?? randomInsertId();
try {
const resolvedDistinctId =
distinctId && distinctId.length > 0
? distinctId
: await readInstallationIdSafe(args.dataDir);
client.capture({
distinctId: resolvedDistinctId,
event: eventName,
properties: {
...properties,
event_id: resolvedInsertId,
event_schema_version: EVENT_SCHEMA_VERSION,
ui_version: appVersion,
app_version: appVersion,
device_id: resolvedDistinctId,
client_type: 'daemon',
capture_source: 'daemon/safety',
$insert_id: resolvedInsertId,
},
});
} catch {
// Capture failures must never propagate. The whole point of this
// path is best-effort observability into a degraded state.
}
},
shutdown: async () => { shutdown: async () => {
try { try {
await client.shutdown(); await client.shutdown();
@ -175,6 +237,24 @@ export function createAnalyticsService(args: {
}; };
} }
const SYNTHETIC_DISTINCT_ID = `daemon-anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
async function readInstallationIdSafe(dataDir: string): Promise<string> {
try {
const cfg = await readAppConfig(dataDir);
if (typeof cfg.installationId === 'string' && cfg.installationId.length > 0) {
return cfg.installationId;
}
} catch {
// fall through to synthetic id
}
return SYNTHETIC_DISTINCT_ID;
}
function randomInsertId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
// Re-export so server.ts and route handlers don't need a second import // Re-export so server.ts and route handlers don't need a second import
// path; the canonical hash lives in @open-design/contracts/analytics so // path; the canonical hash lives in @open-design/contracts/analytics so
// the web bundle produces the same id for the same (projectId, fileName). // the web bundle produces the same id for the same (projectId, fileName).

View file

@ -16,6 +16,13 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import {
readInstallationFile,
resolveInstallationDir,
writeInstallationFile,
type InstallationFilePatch,
} from './installation.js';
// Plugin-system env knobs. See docs/plans/plugins-implementation.md F6 / F9. // Plugin-system env knobs. See docs/plans/plugins-implementation.md F6 / F9.
// Phase 1 only reads them; the GC worker that enforces snapshot expiry lands // Phase 1 only reads them; the GC worker that enforces snapshot expiry lands
// in Phase 5. Centralized here to keep daemon modules from sprinkling magic // in Phase 5. Centralized here to keep daemon modules from sprinkling magic
@ -135,6 +142,15 @@ function validateTelemetry(raw: unknown): TelemetryPrefs | undefined {
} }
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([ const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['amr', new Set([
'VELA_BIN',
'VELA_LINK_URL',
'VELA_RUNTIME_KEY',
'VELA_OPENCODE_BIN',
'OPEN_DESIGN_AMR_PROFILE',
'OPENCODE_TEST_HOME',
])],
['aider', new Set(['AIDER_BIN'])],
['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY'])], ['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY'])],
['codex', new Set(['CODEX_HOME', 'CODEX_BIN', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'OPENAI_API_KEY'])], ['codex', new Set(['CODEX_HOME', 'CODEX_BIN', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'OPENAI_API_KEY'])],
['copilot', new Set(['COPILOT_BIN'])], ['copilot', new Set(['COPILOT_BIN'])],
@ -150,6 +166,7 @@ const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['pi', new Set(['PI_BIN'])], ['pi', new Set(['PI_BIN'])],
['qoder', new Set(['QODER_BIN'])], ['qoder', new Set(['QODER_BIN'])],
['qwen', new Set(['QWEN_BIN'])], ['qwen', new Set(['QWEN_BIN'])],
['trae-cli', new Set(['TRAE_CLI_BIN'])],
['vibe', new Set(['VIBE_BIN'])], ['vibe', new Set(['VIBE_BIN'])],
]); ]);
@ -325,7 +342,58 @@ function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
return result as AppConfigPrefs; return result as AppConfigPrefs;
} }
// Fill in telemetry defaults when the saved config has no `telemetry`
// field at all (fresh install, pre-disclosure). `metrics` / `content`
// default to true so onboarding-funnel events emit from the first
// render — without these defaults the gate at
// `analytics.ts` (`if (cfg.telemetry?.metrics !== true) return`)
// dropped every event a user fired before the post-onboarding
// disclosure modal had a chance to set them. An EXPLICIT `false`
// the user previously saved is preserved (only `undefined` gets
// the new default), so opt-out users stay opted out across the
// 0.7.x → 0.8.0 upgrade.
function applyTelemetryDefaults(prefs: AppConfigPrefs): AppConfigPrefs {
if (prefs.telemetry === undefined) {
return {
...prefs,
telemetry: { metrics: true, content: true, artifactManifest: false },
};
}
return prefs;
}
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> { export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
const base = await readAppConfigFileOnly(dataDir);
// Channel-root installation file is the new authoritative source for the
// identity bits that must survive a namespace-scoped data-dir wipe. It
// lives outside `<namespace>/data/` so a reinstall of the same channel
// (which might churn the namespace token, or eventually clear per-
// namespace data) keeps the same id.
//
// Migration: when this daemon is the first to boot with installation.json
// support and finds an existing installationId in the legacy app-config
// path, mirror it forward exactly once so PostHog continues to see the
// same person across the 0.7.x → 0.8.0 upgrade. Without this mirror, the
// user count would double when 0.8.0 ships.
const installationDir = resolveInstallationDir(dataDir);
const installation = await readInstallationFile(installationDir);
if (typeof installation.installationId === 'string' && installation.installationId.length > 0) {
return applyTelemetryDefaults({ ...base, installationId: installation.installationId });
}
if (typeof base.installationId === 'string' && base.installationId.length > 0) {
// Best-effort migration. A write failure here doesn't break the read —
// we still serve the legacy id. The next write through writeAppConfig
// will retry the mirror.
try {
await writeInstallationFile(installationDir, { installationId: base.installationId });
} catch {
// swallow — observability beats correctness on this path
}
}
return applyTelemetryDefaults(base);
}
async function readAppConfigFileOnly(dataDir: string): Promise<AppConfigPrefs> {
try { try {
const raw = await readFile(configFile(dataDir), 'utf8'); const raw = await readFile(configFile(dataDir), 'utf8');
const parsed: unknown = JSON.parse(raw); const parsed: unknown = JSON.parse(raw);
@ -378,5 +446,26 @@ async function doWrite(
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp'; const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8'); await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await rename(tmp, file); await rename(tmp, file);
// Mirror the identity bits to the channel-root installation file so they
// survive a namespace-scoped data-dir wipe. Only fires when the caller
// explicitly touched `installationId` (avoiding noisy writes on every
// unrelated app-config update). A write failure here doesn't roll back
// the app-config write — the next read merges them transparently.
if (Object.prototype.hasOwnProperty.call(partial, 'installationId')) {
const id = next.installationId;
// Caller explicitly touched installationId — mirror the outcome
// (including the clear case) to installation.json so a future read
// doesn't keep serving the old value out of the channel-root file.
// "Delete my data" relies on this clear path.
const installPatch: InstallationFilePatch = {
installationId: typeof id === 'string' && id.length > 0 ? id : null,
};
try {
await writeInstallationFile(resolveInstallationDir(dataDir), installPatch);
} catch {
// swallow — install file mirroring is best-effort; the canonical
// app-config write already succeeded.
}
}
return next as AppConfigPrefs; return next as AppConfigPrefs;
} }

View file

@ -74,6 +74,13 @@ function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
} }
function inferReleaseChannelFromVersion(version: string): string | null {
if (/(?:^|[-.])beta(?:[-.]|$)/i.test(version)) return 'beta';
if (/(?:^|[-.])preview(?:[-.]|$)/i.test(version)) return 'preview';
if (/(?:^|[-.])nightly(?:[-.]|$)/i.test(version)) return 'nightly';
return version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null;
}
export function isPackagedRuntime({ export function isPackagedRuntime({
resourcesPath = processWithResources.resourcesPath, resourcesPath = processWithResources.resourcesPath,
execPath = process.execPath, execPath = process.execPath,
@ -109,10 +116,10 @@ export function resolveAppVersionInfo({
const version = cleanString(env.OD_APP_VERSION) const version = cleanString(env.OD_APP_VERSION)
?? cleanString(packageMetadata?.version) ?? cleanString(packageMetadata?.version)
?? APP_VERSION_FALLBACK; ?? APP_VERSION_FALLBACK;
const prereleaseChannel = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null; const inferredChannel = inferReleaseChannelFromVersion(version);
const channel = cleanString(env.OD_RELEASE_CHANNEL) const channel = cleanString(env.OD_RELEASE_CHANNEL)
?? cleanString(env.OD_APP_CHANNEL) ?? cleanString(env.OD_APP_CHANNEL)
?? prereleaseChannel ?? inferredChannel
?? (packaged ? 'stable' : 'development'); ?? (packaged ? 'stable' : 'development');
return { version, channel, packaged, platform, arch }; return { version, channel, packaged, platform, arch };

View file

@ -132,6 +132,13 @@ export function validateArtifactManifestInput(
} }
} }
if (manifest.primary !== undefined) {
if (manifest.primary !== true) {
const primaryErr = validateSupportingPath(manifest.primary);
if (primaryErr) return { ok: false, error: `artifactManifest.primary ${primaryErr}` };
}
}
if (manifest.supportingFiles !== undefined) { if (manifest.supportingFiles !== undefined) {
if (!Array.isArray(manifest.supportingFiles)) { if (!Array.isArray(manifest.supportingFiles)) {
return { ok: false, error: 'artifactManifest.supportingFiles must be an array' }; return { ok: false, error: 'artifactManifest.supportingFiles must be an array' };
@ -216,6 +223,12 @@ export function sanitizeManifest(
renderer: manifest.renderer, renderer: manifest.renderer,
status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete', status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
exports: manifest.exports, exports: manifest.exports,
primary:
manifest.primary === true
? true
: typeof manifest.primary === 'string'
? manifest.primary.replace(/\\/g, '/')
: undefined,
supportingFiles: Array.isArray(manifest.supportingFiles) supportingFiles: Array.isArray(manifest.supportingFiles)
? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/')) ? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/'))
: undefined, : undefined,

View file

@ -9,10 +9,8 @@
// back as a `role: 'tool'` message → re-issue the completion. The chat surface // back as a `role: 'tool'` message → re-issue the completion. The chat surface
// stays the same; the tool dispatch happens entirely daemon-side. // stays the same; the tool dispatch happens entirely daemon-side.
// //
// Today we ship one tool — `generate_image` — backed by SenseAudio's // Today we ship image, video, and speech tools backed by SenseAudio endpoints,
// /v1/image/sync endpoint, since the BYOK chat session already authenticates // since the BYOK chat session already authenticates with the same API key.
// against SenseAudio with the same API key. Additional tools (TTS, video,
// research) can be added here as the BYOK surface expands.
import path from 'node:path'; import path from 'node:path';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
@ -43,6 +41,18 @@ export function isSenseAudioImageModel(value: unknown): value is string {
const SENSEAUDIO_DEFAULT_BASE_URL = 'https://api.senseaudio.cn'; const SENSEAUDIO_DEFAULT_BASE_URL = 'https://api.senseaudio.cn';
const PROMPT_MAX_LENGTH = 2000; const PROMPT_MAX_LENGTH = 2000;
const SENSEAUDIO_TTS_MODEL = 'senseaudio-tts-1.5-260319';
const SENSEAUDIO_DEFAULT_VOICE_ID = 'female_0033_b';
const HEX_AUDIO_PATTERN = /^[0-9a-fA-F]+$/;
function appendSenseAudioApiPath(baseUrl: string, path: string): string {
const url = new URL(baseUrl);
const trimmed = url.pathname.replace(/\/+$/, '');
url.pathname = /\/v\d+(\/|$)/.test(trimmed)
? `${trimmed}${path}`
: `${trimmed}/v1${path}`;
return url.toString();
}
// SenseAudio video — the API only documents one model today, so the // SenseAudio video — the API only documents one model today, so the
// wire id is a const. The chat tool's `generate_video` param surface // wire id is a const. The chat tool's `generate_video` param surface
@ -122,6 +132,30 @@ export const BYOK_SENSEAUDIO_TOOLS = [
}, },
}, },
}, },
{
type: 'function' as const,
function: {
name: 'generate_speech',
description:
'Generate a text-to-speech voiceover using SenseAudio TTS. Returns a URL pointing to the rendered MP3. Use this whenever the user asks for narration, voiceover, speech, TTS, or spoken audio. After this tool succeeds, reply with a clickable markdown link to the MP3.',
parameters: {
type: 'object',
properties: {
text: {
type: 'string',
description:
'Exact script to speak. Include only the words that should be spoken, not production notes.',
},
voice_id: {
type: 'string',
description:
`Optional SenseAudio voice id. Defaults to ${SENSEAUDIO_DEFAULT_VOICE_ID}.`,
},
},
required: ['text'],
},
},
},
{ {
type: 'function' as const, type: 'function' as const,
function: { function: {
@ -206,6 +240,10 @@ export interface BYOKToolContext {
* (e.g. 1 ms) to keep the suite fast without changing the polling * (e.g. 1 ms) to keep the suite fast without changing the polling
* semantics. */ * semantics. */
videoPollIntervalMs?: number; videoPollIntervalMs?: number;
/** Optional per-request init copied from the live chat turn. Used to
* forward the current proxy dispatcher into every upstream/download
* fetch the BYOK tool executor performs. */
requestInit?: Pick<RequestInit, 'dispatcher'>;
} }
export interface ImageToolResult { export interface ImageToolResult {
@ -217,6 +255,112 @@ export interface ImageToolResult {
error?: string; error?: string;
} }
function withToolRequestInit(
ctx: BYOKToolContext,
init: RequestInit,
): RequestInit {
return {
...ctx.requestInit,
...init,
};
}
export async function executeGenerateSpeech(
args: { text?: unknown; voice_id?: unknown },
ctx: BYOKToolContext,
): Promise<ImageToolResult> {
const text = typeof args.text === 'string' ? args.text.trim() : '';
if (!text) return { ok: false, error: 'text is required' };
let dir: string;
try {
dir = await ensureProject(ctx.projectsRoot, ctx.projectId);
} catch (err) {
return {
ok: false,
error: `invalid projectId for speech storage: ${err instanceof Error ? err.message : String(err)}`,
};
}
const apiKey = ctx.upstreamApiKey;
if (!apiKey) return { ok: false, error: 'no SenseAudio API key available' };
const voiceId =
typeof args.voice_id === 'string' && args.voice_id.trim()
? args.voice_id.trim()
: SENSEAUDIO_DEFAULT_VOICE_ID;
const baseUrl = ctx.upstreamBaseUrl || SENSEAUDIO_DEFAULT_BASE_URL;
let data: {
data?: { audio?: string };
base_resp?: { status_code?: number; status_msg?: string };
};
try {
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), withToolRequestInit(ctx, {
method: 'POST',
redirect: 'error',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model: SENSEAUDIO_TTS_MODEL,
text,
stream: false,
voice_setting: {
voice_id: voiceId,
speed: 1,
vol: 1,
pitch: 0,
},
audio_setting: {
format: 'mp3',
sample_rate: 32000,
bitrate: 128000,
channel: 2,
},
}),
}));
const respText = await resp.text();
if (!resp.ok) {
return { ok: false, error: `senseaudio speech ${resp.status}: ${respText.slice(0, 240)}` };
}
try {
data = JSON.parse(respText) as typeof data;
} catch {
return { ok: false, error: `senseaudio speech non-JSON: ${respText.slice(0, 200)}` };
}
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
if (data?.base_resp && data.base_resp.status_code !== 0) {
return {
ok: false,
error: `senseaudio speech api error ${data.base_resp.status_code}: ${data.base_resp.status_msg || 'unknown'}`,
};
}
const hex = data?.data?.audio;
if (typeof hex !== 'string' || !hex) {
return { ok: false, error: 'senseaudio speech response missing data.audio' };
}
if (hex.length % 2 !== 0 || !HEX_AUDIO_PATTERN.test(hex)) {
return { ok: false, error: 'senseaudio speech response contained invalid hex audio' };
}
const bytes = Buffer.from(hex, 'hex');
if (bytes.length === 0) return { ok: false, error: 'senseaudio speech decoded zero bytes' };
const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
const filename = `byok-speech-${id}.mp3`;
await writeFile(path.join(dir, filename), bytes);
return {
ok: true,
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
};
}
function sanitizeAspectRatio(raw: unknown): string { function sanitizeAspectRatio(raw: unknown): string {
if (typeof raw !== 'string') return '1:1'; if (typeof raw !== 'string') return '1:1';
return ASPECT_TO_SIZE[raw] ? raw : '1:1'; return ASPECT_TO_SIZE[raw] ? raw : '1:1';
@ -290,7 +434,7 @@ export async function executeGenerateImage(
const trimmedBase = baseUrl.replace(/\/+$/, ''); const trimmedBase = baseUrl.replace(/\/+$/, '');
let imageUrl: string; let imageUrl: string;
try { try {
const resp = await fetch(`${trimmedBase}/v1/image/sync`, { const resp = await fetch(`${trimmedBase}/v1/image/sync`, withToolRequestInit(ctx, {
method: 'POST', method: 'POST',
headers: { headers: {
authorization: `Bearer ${apiKey}`, authorization: `Bearer ${apiKey}`,
@ -301,7 +445,7 @@ export async function executeGenerateImage(
prompt, prompt,
size, size,
}), }),
}); }));
if (!resp.ok) { if (!resp.ok) {
const text = await resp.text().catch(() => ''); const text = await resp.text().catch(() => '');
return { return {
@ -339,7 +483,7 @@ export async function executeGenerateImage(
let bytes: Buffer; let bytes: Buffer;
try { try {
const imgResp = await fetch(imageUrl, { redirect: 'error' }); const imgResp = await fetch(imageUrl, withToolRequestInit(ctx, { redirect: 'error' }));
if (!imgResp.ok) { if (!imgResp.ok) {
return { ok: false, error: `image download ${imgResp.status}` }; return { ok: false, error: `image download ${imgResp.status}` };
} }
@ -466,7 +610,7 @@ export async function executeGenerateVideo(
// Step 1: POST /v1/video/create → task_id. // Step 1: POST /v1/video/create → task_id.
let taskId: string; let taskId: string;
try { try {
const resp = await fetch(`${trimmedBase}/v1/video/create`, { const resp = await fetch(`${trimmedBase}/v1/video/create`, withToolRequestInit(ctx, {
method: 'POST', method: 'POST',
headers: { headers: {
authorization: `Bearer ${apiKey}`, authorization: `Bearer ${apiKey}`,
@ -480,7 +624,7 @@ export async function executeGenerateVideo(
ratio, ratio,
provider_specific: { generate_audio: generateAudio }, provider_specific: { generate_audio: generateAudio },
}), }),
}); }));
if (!resp.ok) { if (!resp.ok) {
const text = await resp.text().catch(() => ''); const text = await resp.text().catch(() => '');
return { return {
@ -509,10 +653,10 @@ export async function executeGenerateVideo(
try { try {
statusResp = await fetch( statusResp = await fetch(
`${trimmedBase}/v1/video/status?id=${encodeURIComponent(taskId)}`, `${trimmedBase}/v1/video/status?id=${encodeURIComponent(taskId)}`,
{ withToolRequestInit(ctx, {
method: 'GET', method: 'GET',
headers: { authorization: `Bearer ${apiKey}` }, headers: { authorization: `Bearer ${apiKey}` },
}, }),
); );
} catch (err) { } catch (err) {
return { return {
@ -572,7 +716,7 @@ export async function executeGenerateVideo(
let bytes: Buffer; let bytes: Buffer;
try { try {
const videoResp = await fetch(videoUrl, { redirect: 'error' }); const videoResp = await fetch(videoUrl, withToolRequestInit(ctx, { redirect: 'error' }));
if (!videoResp.ok) { if (!videoResp.ok) {
return { ok: false, error: `video download ${videoResp.status}` }; return { ok: false, error: `video download ${videoResp.status}` };
} }
@ -595,4 +739,3 @@ export async function executeGenerateVideo(
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`, url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
}; };
} }

View file

@ -1,18 +1,45 @@
import type { Express } from 'express'; import type { Express } from 'express';
import type { RouteDeps } from './server-context.js'; import type { RouteDeps } from './server-context.js';
import { seedProviderIfMissing } from './media-config.js'; import { seedProviderIfMissing } from './media-config.js';
import {
buildLegacyMaxTokensParam,
buildMaxCompletionTokensParam,
buildOpenAIChatTokenParam,
isUnsupportedMaxTokensError,
} from './openai-chat-token-params.js';
import { import {
BYOK_SENSEAUDIO_TOOLS, BYOK_SENSEAUDIO_TOOLS,
executeGenerateImage, executeGenerateImage,
executeGenerateSpeech,
executeGenerateVideo, executeGenerateVideo,
isSenseAudioImageModel, isSenseAudioImageModel,
type BYOKToolContext, type BYOKToolContext,
} from './byok-tools.js'; } from './byok-tools.js';
import { isSafeId as isSafeProjectId } from './projects.js'; import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics'; import { projectKindToTracking } from '@open-design/contracts/analytics';
import { validateBaseUrlResolved } from './connectionTest.js'; import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths'> {} // Allowlist for the `/feedback` route. Mirrors the
// ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts.
// Kept inline (not imported as a runtime value, since the contract type is
// type-only) so a stale client can't poison Langfuse with unknown categories.
const FEEDBACK_REASON_ALLOWLIST: ReadonlySet<string> = new Set([
'matched_request',
'strong_visual',
'useful_structure',
'easy_to_continue',
'followed_design_system',
'missed_request',
'weak_visual',
'incomplete_output',
'hard_to_use',
'missed_design_system',
'other',
]);
export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths' | 'telemetry'> {}
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx; const { db, design } = ctx;
@ -122,13 +149,89 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.json({ ok: true }); res.json({ ok: true });
}); });
// Receives the user's thumbs-up/down (+ reason codes) for an assistant
// turn and forwards it to Langfuse as a `score-create`. Web persists the
// feedback itself via PUT /messages/:id; this endpoint exists only as a
// telemetry side channel — the daemon is the single network egress for
// Langfuse and gates on `telemetry.metrics + telemetry.content` consent.
//
// The consent + sink decision is fast (awaits a small file read, no
// network); we await it so the response status honestly reflects whether
// the score was enqueued, skipped for consent, or skipped because no
// Langfuse sink is configured. The actual Langfuse network call happens
// as a detached promise inside the bridge.
app.post('/api/runs/:id/feedback', async (req, res) => {
const runId = req.params.id;
const body = (req.body ?? {}) as Partial<{
projectId: string;
conversationId: string;
assistantMessageId: string;
rating: 'positive' | 'negative';
reasonCodes: string[];
hasCustomReason: boolean;
customReason: string;
}>;
if (!runId) {
return sendApiError(res, 400, 'INVALID_RUN_ID', 'runId missing');
}
if (body.rating !== 'positive' && body.rating !== 'negative') {
return sendApiError(res, 400, 'INVALID_RATING', 'rating must be positive or negative');
}
// Drop anything outside the contract-side reason allowlist and
// deduplicate; otherwise a malformed or replayed client payload could
// create unknown Langfuse categories or duplicate score ids in the
// same batch.
const reasonCodes = Array.isArray(body.reasonCodes)
? Array.from(
new Set(
body.reasonCodes.filter(
(c): c is string =>
typeof c === 'string' && FEEDBACK_REASON_ALLOWLIST.has(c),
),
),
)
: [];
const customReason = typeof body.customReason === 'string' ? body.customReason : '';
const reportFeedback = ctx.telemetry?.reportFeedback;
if (!reportFeedback) {
res.status(202).json({ status: 'skipped_no_sink' });
return;
}
// Build score metadata bag that lands in the Langfuse score body.
// Mirrors the PostHog event so analysts can cross-reference.
const scoreMetadata: Record<string, unknown> = {
projectId: body.projectId,
conversationId: body.conversationId,
assistantMessageId: body.assistantMessageId,
hasCustomReason: body.hasCustomReason === true,
customReason,
};
const outcome = await reportFeedback({
runId,
rating: body.rating,
reasonCodes,
hasCustomReason: body.hasCustomReason === true,
customReason,
scoreMetadata,
});
res.status(202).json(outcome);
});
app.post('/api/chat', (req, res) => { app.post('/api/chat', (req, res) => {
if (isDaemonShuttingDown()) { if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down'); return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
} }
const run = design.runs.create(); const body = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(
(body as { mediaExecution?: unknown }).mediaExecution,
);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const runBody = { ...body, mediaExecution: mediaExecution.policy };
const run = design.runs.create(runBody);
design.runs.stream(run, req, res); design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(req.body || {}, run)); design.runs.start(run, () => startChatRun(runBody, run));
}); });
// ---- Connection tests (single-shot JSON; no SSE) ------------------------ // ---- Connection tests (single-shot JSON; no SSE) ------------------------
@ -176,15 +279,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
); );
} }
try { try {
const result = await listProviderModels({ const proxyDispatcher = proxyDispatcherRequestInit();
protocol, try {
baseUrl: body.baseUrl, const result = await listProviderModels({
apiKey: body.apiKey, protocol,
apiVersion: baseUrl: body.baseUrl,
typeof body.apiVersion === 'string' ? body.apiVersion : undefined, apiKey: body.apiKey,
signal: controller.signal, apiVersion:
}); typeof body.apiVersion === 'string' ? body.apiVersion : undefined,
return res.json(result); signal: controller.signal,
requestInit: proxyDispatcher.requestInit,
});
return res.json(result);
} finally {
await proxyDispatcher.close();
}
} catch (err: any) { } catch (err: any) {
console.warn( console.warn(
`[provider:models] uncaught: ${err instanceof Error ? err.message : String(err)}`, `[provider:models] uncaught: ${err instanceof Error ? err.message : String(err)}`,
@ -577,9 +686,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
} }
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model }); let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, { const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -628,6 +740,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:anthropic] internal error: ${err.message}`); console.error(`[proxy:anthropic] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });
@ -669,15 +783,20 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const payload: any = { const payload: any = {
model, model,
messages: payloadMessages, messages: payloadMessages,
max_tokens: ...buildOpenAIChatTokenParam(
model,
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192, typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192,
),
stream: true, stream: true,
}; };
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model }); let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, { const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -724,6 +843,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:openai] internal error: ${err.message}`); console.error(`[proxy:openai] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });
@ -779,38 +900,70 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
payloadMessages.unshift({ role: 'system', content: systemPrompt }); payloadMessages.unshift({ role: 'system', content: systemPrompt });
} }
const effectiveMaxTokens =
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192;
const payload = { const payload = {
...(usesVersionedOpenAIPath ? { model } : {}), ...(usesVersionedOpenAIPath ? { model } : {}),
messages: payloadMessages, messages: payloadMessages,
max_tokens: ...buildLegacyMaxTokensParam(effectiveMaxTokens),
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192, stream: true,
};
const retryPayload = {
...(usesVersionedOpenAIPath ? { model } : {}),
messages: payloadMessages,
...buildMaxCompletionTokensParam(effectiveMaxTokens),
stream: true, stream: true,
}; };
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model }); let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
const response = await fetch(url, { proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const requestInit = {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'api-key': apiKey, 'api-key': apiKey,
}, },
redirect: 'error' as const,
};
let response = await fetch(url, {
...requestInit,
body: JSON.stringify(payload), body: JSON.stringify(payload),
redirect: 'error',
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); let errorText = await response.text();
console.error( if (
`[proxy:azure] upstream error: ${response.status} ${redactAuthTokens(errorText)}`, response.status === 400 &&
); isUnsupportedMaxTokensError(errorText)
sendProxyError(sse, `Upstream error: ${response.status}`, { ) {
code: proxyErrorCode(response.status), console.warn(
details: errorText, `[proxy:azure] retrying request with max_completion_tokens deployment=${model}`,
retryable: response.status === 429 || response.status >= 500, );
}); response = await fetch(url, {
return sse.end(); ...requestInit,
body: JSON.stringify(retryPayload),
});
if (response.ok) {
errorText = '';
} else {
errorText = await response.text();
}
}
if (!response.ok) {
console.error(
`[proxy:azure] upstream error: ${response.status} ${redactAuthTokens(errorText)}`,
);
sendProxyError(sse, `Upstream error: ${response.status}`, {
code: proxyErrorCode(response.status),
details: errorText,
retryable: response.status === 429 || response.status >= 500,
});
return sse.end();
}
} }
let ended = false; let ended = false;
@ -837,6 +990,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:azure] internal error: ${err.message}`); console.error(`[proxy:azure] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });
@ -865,8 +1020,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
); );
} }
const clean = effectiveBaseUrl.replace(/\/+$/, ''); const url = googleStreamGenerateContentUrl(effectiveBaseUrl, model);
const url = `${clean}/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
console.log( console.log(
`[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`, `[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`,
); );
@ -887,9 +1041,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
} }
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model }); let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, { const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -937,6 +1094,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:google] internal error: ${err.message}`); console.error(`[proxy:google] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });
@ -974,9 +1133,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
} }
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model }); let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, { const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -1012,6 +1174,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:ollama] internal error: ${err.message}`); console.error(`[proxy:ollama] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });
@ -1107,12 +1271,15 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
? byokImageModel ? byokImageModel
: undefined; : undefined;
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
const toolCtx: BYOKToolContext = { const toolCtx: BYOKToolContext = {
projectRoot: ctx.paths.PROJECT_ROOT, projectRoot: ctx.paths.PROJECT_ROOT,
projectsRoot: ctx.paths.PROJECTS_DIR, projectsRoot: ctx.paths.PROJECTS_DIR,
projectId, projectId,
upstreamApiKey: apiKey, upstreamApiKey: apiKey,
upstreamBaseUrl: effectiveBaseUrl, upstreamBaseUrl: effectiveBaseUrl,
requestInit: {},
// Spread-conditional because tsconfig's exactOptionalPropertyTypes // Spread-conditional because tsconfig's exactOptionalPropertyTypes
// forbids `field: undefined` on an optional slot. The byok-tools // forbids `field: undefined` on an optional slot. The byok-tools
// executor reads `ctx.defaultImageModel` with `isSenseAudioImageModel` // executor reads `ctx.defaultImageModel` with `isSenseAudioImageModel`
@ -1141,6 +1308,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
tool_choice: 'auto', tool_choice: 'auto',
}; };
const response = await fetch(url, { const response = await fetch(url, {
...toolCtx.requestInit,
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -1255,32 +1423,35 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const executeOneTool = async (call: { const executeOneTool = async (call: {
id: string; id: string;
function: { name: string; arguments: string }; function: { name: string; arguments: string };
}): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' }> => { }): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' | 'speech' }> => {
const fnName = call?.function?.name ?? ''; const fnName = call?.function?.name ?? '';
if (fnName !== 'generate_image' && fnName !== 'generate_video') { if (fnName !== 'generate_image' && fnName !== 'generate_video' && fnName !== 'generate_speech') {
return { return {
ok: false, ok: false,
error: `unknown tool: ${fnName || 'unnamed'}`, error: `unknown tool: ${fnName || 'unnamed'}`,
}; };
} }
const toolKind = fnName === 'generate_image' ? 'image' : fnName === 'generate_video' ? 'video' : 'speech';
let args: any = {}; let args: any = {};
try { try {
args = JSON.parse(call.function.arguments || '{}'); args = JSON.parse(call.function.arguments || '{}');
} catch { } catch {
return { ok: false, error: 'tool arguments were not valid JSON' }; return { ok: false, error: 'tool arguments were not valid JSON', kind: toolKind };
} }
if (fnName === 'generate_image') { if (fnName === 'generate_image') {
const result = await executeGenerateImage(args, toolCtx); const result = await executeGenerateImage(args, toolCtx);
return { ...result, kind: 'image' }; return { ...result, kind: 'image' };
} }
if (fnName === 'generate_speech') {
const result = await executeGenerateSpeech(args, toolCtx);
return { ...result, kind: 'speech' };
}
// generate_video — longer (up to 5 min), async-with-polling. // generate_video — longer (up to 5 min), async-with-polling.
const result = await executeGenerateVideo(args, toolCtx); const result = await executeGenerateVideo(args, toolCtx);
return { ...result, kind: 'video' }; return { ...result, kind: 'video' };
}; };
const sse = createSseResponse(res); const sse = createSseResponse(res);
sse.send('start', { model });
// SenseAudio's gateway issues one API key that works for both // SenseAudio's gateway issues one API key that works for both
// /v1/chat/completions and the image / TTS surfaces. Mirror the // /v1/chat/completions and the image / TTS surfaces. Mirror the
// BYOK key into media-config so the CLI agent path (`od media // BYOK key into media-config so the CLI agent path (`od media
@ -1307,6 +1478,9 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
}); });
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
toolCtx.requestInit = proxyDispatcher.requestInit;
sse.send('start', { model });
for (let loop = 0; loop < MAX_BYOK_TOOL_LOOPS; loop++) { for (let loop = 0; loop < MAX_BYOK_TOOL_LOOPS; loop++) {
const turn = await runSenseAudioTurn(sse, workingMessages); const turn = await runSenseAudioTurn(sse, workingMessages);
if (turn.kind === 'error') return sse.end(); if (turn.kind === 'error') return sse.end();
@ -1339,9 +1513,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const content = result.ok const content = result.ok
? result.kind === 'video' ? result.kind === 'video'
? `Video generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link, e.g. [▶ Play video](${result.url}). Do NOT use markdown image syntax — the chat renderer does not embed <video> tags.` ? `Video generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link, e.g. [▶ Play video](${result.url}). Do NOT use markdown image syntax — the chat renderer does not embed <video> tags.`
: result.kind === 'speech'
? `Speech generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link to the MP3, e.g. [▶ Play voiceover](${result.url}).`
: `Image generated successfully. URL: ${result.url}. Reply to the user with: ![generated image](${result.url})` : `Image generated successfully. URL: ${result.url}. Reply to the user with: ![generated image](${result.url})`
: result.kind === 'video' : result.kind === 'video'
? `Video generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt or a shorter duration.` ? `Video generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt or a shorter duration.`
: result.kind === 'speech'
? `Speech generation failed: ${result.error}. Apologize briefly and suggest a retry with a shorter script or a valid voice id.`
: `Image generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt.`; : `Image generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt.`;
workingMessages.push({ workingMessages.push({
role: 'tool', role: 'tool',
@ -1362,6 +1540,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:senseaudio] internal error: ${err.message}`); console.error(`[proxy:senseaudio] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end(); sse.end();
} finally {
await proxyDispatcher?.close();
} }
}); });

View file

@ -78,6 +78,8 @@ export function diagnoseClaudeCliFailure(
const authFailure = const authFailure =
/\b401\b/.test(text) || /\b401\b/.test(text) ||
/apikeysource["'\s:]+none/i.test(text) || /apikeysource["'\s:]+none/i.test(text) ||
/not logged in/i.test(text) ||
/please run \/login/i.test(text) ||
/(auth|oauth|credential|token).*(fail|invalid|missing|expired|not found|none|unauthorized)/i.test(text) || /(auth|oauth|credential|token).*(fail|invalid|missing|expired|not found|none|unauthorized)/i.test(text) ||
/(unauthorized|invalid api key|missing api key|could not authenticate|authentication failed)/i.test(text); /(unauthorized|invalid api key|missing api key|could not authenticate|authentication failed)/i.test(text);
if (authFailure && hasCustomBaseUrl) { if (authFailure && hasCustomBaseUrl) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
// Thin wrapper over `codex mcp add|remove|get` so the Settings panel can
// offer a one-click "Install to Codex" toggle instead of asking the user
// to paste TOML into ~/.codex/config.toml. We shell out to the bundled
// Codex CLI rather than rewriting config.toml ourselves so we inherit
// Codex's own merge / dedupe / validation rules.
//
// CodexRunner is injected so tests can stub spawn without poking the
// global child_process module; production uses defaultCodexRunner which
// is a thin spawn() wrapper with a 30s timeout.
import { spawn } from 'node:child_process';
export interface CodexRunnerResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface CodexRunner {
run(args: string[], opts?: { env?: Record<string, string> }): Promise<CodexRunnerResult>;
}
const defaultCodexRunner: CodexRunner = {
run(args, opts) {
return new Promise<CodexRunnerResult>((resolve, reject) => {
const child = spawn('codex', args, {
env: { ...process.env, ...(opts?.env ?? {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('codex CLI timed out after 30s'));
}, 30_000);
child.stdout?.on('data', (d) => {
stdout += String(d);
});
child.stderr?.on('data', (d) => {
stderr += String(d);
});
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({ exitCode: code ?? -1, stdout, stderr });
});
});
},
};
let _runner: CodexRunner | null = null;
// Tests inject a stub runner; production callers use the default. Pass
// null to restore the default (called from afterEach in test suites).
export function setCodexRunner(runner: CodexRunner | null): void {
_runner = runner;
}
function activeRunner(): CodexRunner {
return _runner ?? defaultCodexRunner;
}
export interface CodexInstallStatus {
// True when the `codex` CLI was found and is runnable. False = the
// user does not have Codex CLI on PATH (the UI should show the
// one-click button as disabled with an explanatory tooltip).
available: boolean;
// True when an MCP server with `name` is already registered in
// ~/.codex/config.toml. Drives the toggle's "install" vs "uninstall"
// label.
installed: boolean;
}
export async function probeCodexInstall(name: string): Promise<CodexInstallStatus> {
try {
const result = await activeRunner().run(['mcp', 'get', name]);
return { available: true, installed: result.exitCode === 0 };
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ENOENT') {
return { available: false, installed: false };
}
throw err;
}
}
export interface CodexInstallSpec {
// MCP server name as it will appear in ~/.codex/config.toml. We
// hard-code "open-design" at the route layer but keep the parameter
// explicit so the helper can later be reused for other server names.
name: string;
command: string;
args: string[];
env: Record<string, string>;
}
export async function installCodexMcp(spec: CodexInstallSpec): Promise<void> {
const argv: string[] = ['mcp', 'add', spec.name];
for (const [key, value] of Object.entries(spec.env)) {
argv.push('--env', `${key}=${value}`);
}
argv.push('--', spec.command, ...spec.args);
const result = await activeRunner().run(argv);
if (result.exitCode !== 0) {
throw new Error(`codex mcp add failed: ${failureDetail(result)}`);
}
}
export async function uninstallCodexMcp(name: string): Promise<void> {
const result = await activeRunner().run(['mcp', 'remove', name]);
if (result.exitCode !== 0) {
throw new Error(`codex mcp remove failed: ${failureDetail(result)}`);
}
}
function failureDetail(result: CodexRunnerResult): string {
return result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`;
}

View file

@ -21,13 +21,19 @@ import { promises as dnsPromises } from 'node:dns';
import { promises as fsp } from 'node:fs'; import { promises as fsp } from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { Agent, EnvHttpProxyAgent, Socks5ProxyAgent } from 'undici';
import type { Dispatcher, Pool } from 'undici';
import { import {
applyAgentLaunchEnv, applyAgentLaunchEnv,
getAgentDef, getAgentDef,
resolveAgentLaunch, resolveAgentLaunch,
spawnEnvForAgent, spawnEnvForAgent,
} from './agents.js'; } from './agents.js';
import { createCommandInvocation } from '@open-design/platform'; import {
createCommandInvocation,
mergeProxyAwareEnv,
resolveSystemProxyEnv,
} from '@open-design/platform';
import { attachAcpSession } from './acp.js'; import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js'; import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js'; import { createClaudeStreamHandler } from './claude-stream.js';
@ -40,20 +46,30 @@ import {
cursorAuthGuidance, cursorAuthGuidance,
probeAgentAuthStatus, probeAgentAuthStatus,
} from './runtimes/auth.js'; } from './runtimes/auth.js';
import {
buildLegacyMaxTokensParam,
buildMaxCompletionTokensParam,
buildOpenAIChatTokenParam,
isUnsupportedMaxTokensError,
} from './openai-chat-token-params.js';
import type { AgentCliEnvPrefs } from './app-config.js'; import type { AgentCliEnvPrefs } from './app-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js'; import type { RuntimeAgentDef } from './runtimes/types.js';
import { resolveModelForAgent } from './runtimes/models.js';
import { import {
isBlockedExternalApiHostname, isBlockedExternalApiHostname,
isLoopbackApiHost, isLoopbackApiHost,
validateBaseUrl, validateBaseUrl,
type AgentTestRequest, type AgentTestRequest,
type BaseUrlValidationResult, type BaseUrlValidationResult,
type ConnectionTestDiagnostics,
type ConnectionTestKind, type ConnectionTestKind,
type ConnectionTestPhase,
type ConnectionTestProtocol, type ConnectionTestProtocol,
type ConnectionTestResponse, type ConnectionTestResponse,
type ParsedBaseUrl, type ParsedBaseUrl,
type ProviderTestRequest, type ProviderTestRequest,
} from '@open-design/contracts/api/connectionTest'; } from '@open-design/contracts/api/connectionTest';
import { googleGenerateContentUrl } from './google-models.js';
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest'; export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
@ -158,6 +174,7 @@ export async function assertExternalAssetUrl(
// Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks // Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks
// or distant providers; invalid values fall back to the default. // or distant providers; invalid values fall back to the default.
const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000; const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000;
const LOOPBACK_NO_PROXY_TOKENS = ['localhost', '127.0.0.1', '[::1]'] as const;
// CLI boot time is dominated by adapter auth/session restore; the heavy // CLI boot time is dominated by adapter auth/session restore; the heavy
// adapters (Codex, Cursor Agent) regularly take 510 s on a cold first // adapters (Codex, Cursor Agent) regularly take 510 s on a cold first
// run, so 45 s leaves headroom without making a hung child invisible. // run, so 45 s leaves headroom without making a hung child invisible.
@ -201,6 +218,336 @@ function agentTimeoutMs(): number {
DEFAULT_AGENT_TIMEOUT_MS, DEFAULT_AGENT_TIMEOUT_MS,
); );
} }
export function mergeNoProxyWithLoopbackDefaults(noProxy: string | undefined): string | null {
if (noProxy?.split(/[\s,]+/).some((token) => token.trim() === '*')) return '*';
const seen = new Set<string>();
const values: string[] = [];
for (const rawToken of [
...(noProxy ? noProxy.split(/[\s,]+/) : []),
...LOOPBACK_NO_PROXY_TOKENS,
]) {
const token = rawToken.trim() === '::1' ? '[::1]' : rawToken.trim();
if (!token || seen.has(token)) continue;
seen.add(token);
values.push(token);
}
return values.length > 0 ? values.join(',') : null;
}
function defaultPortForProtocol(protocol: string): string {
if (protocol === 'http:') return '80';
if (protocol === 'https:') return '443';
return '';
}
function splitNoProxyHostAndPort(token: string): { host: string; port: string } {
const trimmed = token.trim();
if (!trimmed) return { host: '', port: '' };
if (trimmed.startsWith('[')) {
const closingBracket = trimmed.indexOf(']');
if (closingBracket === -1) return { host: trimmed.toLowerCase(), port: '' };
const host = trimmed.slice(0, closingBracket + 1).toLowerCase();
const port = trimmed.slice(closingBracket + 1).replace(/^:/, '');
return { host, port };
}
const firstColon = trimmed.indexOf(':');
const lastColon = trimmed.lastIndexOf(':');
if (firstColon !== -1 && firstColon === lastColon) {
return {
host: trimmed.slice(0, firstColon).toLowerCase(),
port: trimmed.slice(firstColon + 1),
};
}
return { host: trimmed.toLowerCase(), port: '' };
}
function noProxyTokenMatchesUrl(token: string, url: URL): boolean {
const trimmed = token.trim();
if (!trimmed) return false;
if (trimmed === '*') return true;
if (trimmed === '<local>') return !url.hostname.includes('.') && !url.hostname.includes(':');
const { host, port } = splitNoProxyHostAndPort(trimmed.replace(/^\*\./, '.'));
if (!host) return false;
const normalizedHost = host === '::1' ? '[::1]' : host;
const hostname = url.hostname.toLowerCase();
const matchesHost = normalizedHost.startsWith('.')
? hostname === normalizedHost.slice(1) || hostname.endsWith(normalizedHost)
: hostname === normalizedHost || hostname.endsWith(`.${normalizedHost}`);
if (!matchesHost) return false;
if (!port) return true;
return (url.port || defaultPortForProtocol(url.protocol)) === port;
}
function shouldBypassProxyForUrl(target: string | URL, noProxy: string | null): boolean {
if (!noProxy) return false;
let url: URL;
try {
url = target instanceof URL ? target : new URL(target);
} catch {
return false;
}
return noProxy.split(/[\s,]+/).some((token) => noProxyTokenMatchesUrl(token, url));
}
function socksProxyAgentOptions(
options: Pool.Options,
): ConstructorParameters<typeof Socks5ProxyAgent>[1] {
return {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined ? {} : { headersTimeout: options.headersTimeout }),
};
}
class NoProxyAwareSocksProxyAgent {
private readonly directAgent: Agent;
private readonly socksAgent: Socks5ProxyAgent;
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
constructor(
private readonly noProxy: string | null,
socksProxy: string,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
this.socksDispatchTimeouts = {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined
? {}
: { headersTimeout: options.headersTimeout }),
};
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
const dispatcher =
targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)
? this.directAgent
: this.socksAgent;
return dispatcher.dispatch(
dispatcher === this.socksAgent ? { ...this.socksDispatchTimeouts, ...options } : options,
handler,
);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.socksAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.socksAgent.destroy(error ?? null),
]);
}
}
class NoProxyAwareEnvProxyAgent {
private readonly directAgent: Agent;
constructor(
private readonly noProxy: string,
private readonly proxyAgent: EnvHttpProxyAgent,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
return (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy) ? this.directAgent : this.proxyAgent).dispatch(
options,
handler,
);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.proxyAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.proxyAgent.destroy(error ?? null),
]);
}
}
class NoProxyAwareMixedProxyAgent {
private readonly directAgent: Agent;
private readonly proxyAgent: EnvHttpProxyAgent;
private readonly socksAgent: Socks5ProxyAgent;
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
constructor(
private readonly noProxy: string | null,
private readonly hasHttpProxy: boolean,
private readonly hasHttpsProxy: boolean,
proxyOptions: ConstructorParameters<typeof EnvHttpProxyAgent>[0],
socksProxy: string,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
this.proxyAgent = new EnvHttpProxyAgent(proxyOptions);
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
this.socksDispatchTimeouts = {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined
? {}
: { headersTimeout: options.headersTimeout }),
};
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
if (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)) {
return this.directAgent.dispatch(options, handler);
}
if (
targetUrl && ((targetUrl.protocol === 'http:' && this.hasHttpProxy) ||
(targetUrl.protocol === 'https:' && this.hasHttpsProxy))
) {
return this.proxyAgent.dispatch(options, handler);
}
return this.socksAgent.dispatch({ ...this.socksDispatchTimeouts, ...options }, handler);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.proxyAgent.close(), this.socksAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.proxyAgent.destroy(error ?? null),
this.socksAgent.destroy(error ?? null),
]);
}
}
type ConnectionTestProxyDispatcher =
| EnvHttpProxyAgent
| NoProxyAwareEnvProxyAgent
| NoProxyAwareMixedProxyAgent
| NoProxyAwareSocksProxyAgent;
function envProxyAgentOptions(
options: Pool.Options,
httpProxy: string | undefined,
httpsProxy: string | undefined,
noProxy: string | null,
): ConstructorParameters<typeof EnvHttpProxyAgent>[0] {
return {
...options,
...(httpProxy ? { httpProxy } : {}),
...(httpsProxy ? { httpsProxy } : {}),
...(noProxy ? { noProxy } : {}),
};
}
function buildConnectionTestProxyDispatcher(
env: NodeJS.ProcessEnv = process.env,
options: Pool.Options = {},
): ConnectionTestProxyDispatcher | null {
const proxyEnv = mergeProxyAwareEnv(
process.platform,
resolveSystemProxyEnv(),
env,
);
const allProxy = proxyEnv.ALL_PROXY ?? proxyEnv.all_proxy;
const socksProxy = socksProxyUrl(allProxy);
const httpProxyFromAll = isHttpOrHttpsProxy(allProxy);
const httpProxy = proxyEnv.HTTP_PROXY ?? proxyEnv.http_proxy ?? httpProxyFromAll;
const httpsProxy = proxyEnv.HTTPS_PROXY ?? proxyEnv.https_proxy ?? httpProxyFromAll;
const noProxy = mergeNoProxyWithLoopbackDefaults(proxyEnv.NO_PROXY ?? proxyEnv.no_proxy);
const proxyOptions = envProxyAgentOptions(options, httpProxy, httpsProxy, noProxy);
if (socksProxy && (httpProxy || httpsProxy) && (!httpProxy || !httpsProxy)) {
return new NoProxyAwareMixedProxyAgent(
noProxy,
Boolean(httpProxy),
Boolean(httpsProxy),
proxyOptions,
socksProxy,
options,
);
}
if (!httpProxy && !httpsProxy && socksProxy) {
return new NoProxyAwareSocksProxyAgent(noProxy, socksProxy, options);
}
if (!httpProxy && !httpsProxy) return null;
const proxyAgent = new EnvHttpProxyAgent(proxyOptions);
return noProxy?.split(/[\s,]+/).some((token) => token.trim() === '<local>')
? new NoProxyAwareEnvProxyAgent(noProxy, proxyAgent, options)
: proxyAgent;
}
function isHttpOrHttpsProxy(proxyUrl: string | undefined): string | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) return undefined;
try {
const { protocol } = new URL(trimmed);
return protocol === 'http:' || protocol === 'https:' ? trimmed : undefined;
} catch {
return undefined;
}
}
function socksProxyUrl(proxyUrl: string | undefined): string | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) return undefined;
try {
const url = new URL(trimmed);
if (url.protocol === 'socks:' || url.protocol === 'socks5:') return trimmed;
if (url.protocol === 'socks5h:') {
url.protocol = 'socks5:';
return url.toString();
}
return undefined;
} catch {
return undefined;
}
}
export function proxyDispatcherRequestInit(
env: NodeJS.ProcessEnv = process.env,
options: Pool.Options = {},
): {
close(): Promise<void>;
requestInit: Pick<RequestInit, 'dispatcher'>;
} {
const dispatcher = buildConnectionTestProxyDispatcher(env, options);
if (dispatcher == null) {
return {
async close() {},
requestInit: {},
};
}
return {
close: () => dispatcher.close(),
requestInit: {
dispatcher: dispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
},
};
}
const AGENT_COMPLETION_DEBOUNCE_MS = 500; const AGENT_COMPLETION_DEBOUNCE_MS = 500;
const AGENT_KILL_GRACE_MS = 2_000; const AGENT_KILL_GRACE_MS = 2_000;
// Truncates the assistant reply we surface in the success copy so a // Truncates the assistant reply we surface in the success copy so a
@ -469,6 +816,7 @@ async function validateLocalOpenAiModel(
parsed: ParsedBaseUrl, parsed: ParsedBaseUrl,
signal: AbortSignal, signal: AbortSignal,
start: number, start: number,
requestInit: Pick<RequestInit, 'dispatcher'> = {},
): Promise<ConnectionTestResponse | null> { ): Promise<ConnectionTestResponse | null> {
if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) { if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) {
return null; return null;
@ -478,6 +826,7 @@ async function validateLocalOpenAiModel(
let response: Response; let response: Response;
try { try {
response = await fetch(url, { response = await fetch(url, {
...requestInit,
method: 'GET', method: 'GET',
headers: { authorization: `Bearer ${String(input.apiKey)}` }, headers: { authorization: `Bearer ${String(input.apiKey)}` },
signal, signal,
@ -510,11 +859,124 @@ async function validateLocalOpenAiModel(
}; };
} }
function isSenseAudioNonChatModel(model: string): boolean {
return (
model.startsWith('senseaudio-image-') ||
model.startsWith('doubao-seedream-') ||
model === 'sensenova-u1-fast' ||
model.startsWith('doubao-seedance-') ||
model.startsWith('senseaudio-asr-') ||
model.startsWith('senseaudio-tts-') ||
model.startsWith('senseaudio-music-')
);
}
async function validateSenseAudioNonChatModel(
input: ProviderTestRequest,
signal: AbortSignal,
start: number,
requestInit: Pick<RequestInit, 'dispatcher'> = {},
): Promise<ConnectionTestResponse | null> {
if (input.protocol !== 'senseaudio' || !isSenseAudioNonChatModel(input.model)) {
return null;
}
const url = appendVersionedApiPath(String(input.baseUrl), '/models');
let response: Response;
try {
response = await fetch(url, {
...requestInit,
method: 'GET',
headers: { authorization: `Bearer ${String(input.apiKey)}` },
signal,
redirect: 'error',
});
} catch (err) {
const latencyMs = Date.now() - start;
const kind = networkErrorToKind(err);
return {
ok: false,
kind,
latencyMs,
model: input.model,
detail: redactSecrets(err instanceof Error ? err.message : String(err), [
input.apiKey,
]),
};
}
const latencyMs = Date.now() - start;
let rawText = '';
let data: unknown = {};
let parseError: unknown = null;
try {
rawText = await response.text();
} catch {
rawText = '';
}
try {
data = rawText ? JSON.parse(rawText) : {};
} catch (err) {
parseError = err;
}
if (parseError && response.ok) {
return {
ok: false,
kind: 'unknown',
latencyMs,
model: input.model,
status: response.status,
detail: redactSecrets(
parseError instanceof Error ? parseError.message : String(parseError),
[input.apiKey],
),
};
}
if (!response.ok) {
const redactedDetail = redactSecrets(
extractProviderErrorDetail(data, rawText).slice(0, 240),
[input.apiKey],
);
return {
ok: false,
kind: statusToKind(response.status, redactedDetail),
latencyMs,
model: input.model,
status: response.status,
detail: redactedDetail,
};
}
const modelIds = extractOpenAiModelIds(data);
if (!modelIds.includes(input.model)) {
return {
ok: false,
kind: 'not_found_model',
latencyMs,
model: input.model,
status: response.status,
detail: `Model "${input.model}" is not reported by SenseAudio /models.`,
};
}
return {
ok: true,
kind: 'success',
latencyMs,
model: input.model,
status: response.status,
detail: 'SenseAudio model is available, but this media model is not chat-testable from Settings.',
};
}
interface ProviderCallShape { interface ProviderCallShape {
url: string; url: string;
headers: Record<string, string>; headers: Record<string, string>;
body: unknown; body: unknown;
extractText: (data: unknown) => string; extractText: (data: unknown) => string;
retryBodyOnUnsupportedMaxTokens?: unknown;
} }
function buildProviderCall(input: ProviderTestRequest): ProviderCallShape { function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
@ -567,7 +1029,7 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
}, },
body: { body: {
model, model,
max_tokens: PROVIDER_MAX_TOKENS, ...buildOpenAIChatTokenParam(model, PROVIDER_MAX_TOKENS),
messages: [{ role: 'user', content: SMOKE_PROMPT }], messages: [{ role: 'user', content: SMOKE_PROMPT }],
stream: false, stream: false,
}, },
@ -600,17 +1062,22 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
}, },
body: { body: {
...(usesVersionedOpenAIPath ? { model } : {}), ...(usesVersionedOpenAIPath ? { model } : {}),
max_tokens: PROVIDER_MAX_TOKENS, ...buildLegacyMaxTokensParam(PROVIDER_MAX_TOKENS),
messages: [{ role: 'user', content: SMOKE_PROMPT }], messages: [{ role: 'user', content: SMOKE_PROMPT }],
stream: false, stream: false,
}, },
retryBodyOnUnsupportedMaxTokens: {
...(usesVersionedOpenAIPath ? { model } : {}),
messages: [{ role: 'user', content: SMOKE_PROMPT }],
stream: false,
...buildMaxCompletionTokensParam(PROVIDER_MAX_TOKENS),
},
extractText: extractOpenAIMessageText, extractText: extractOpenAIMessageText,
}; };
} }
case 'google': { case 'google': {
const trimmedBase = baseUrl.replace(/\/+$/, '');
return { return {
url: `${trimmedBase}/v1beta/models/${encodeURIComponent(model)}:generateContent`, url: googleGenerateContentUrl(baseUrl, model),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
'x-goog-api-key': apiKey, 'x-goog-api-key': apiKey,
@ -716,24 +1183,81 @@ export async function testProviderConnection(
input.signal?.addEventListener('abort', abortFromParent, { once: true }); input.signal?.addEventListener('abort', abortFromParent, { once: true });
} }
const timer = setTimeout(() => controller.abort(), providerTimeoutMs()); const timer = setTimeout(() => controller.abort(), providerTimeoutMs());
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try { try {
proxyDispatcher = proxyDispatcherRequestInit();
const modelError = await validateLocalOpenAiModel( const modelError = await validateLocalOpenAiModel(
input, input,
validated.parsed, validated.parsed,
controller.signal, controller.signal,
start, start,
proxyDispatcher.requestInit,
); );
if (modelError) return modelError; if (modelError) return modelError;
const response = await fetch(call.url, { const senseAudioNonChatResult = await validateSenseAudioNonChatModel(
input,
controller.signal,
start,
proxyDispatcher.requestInit,
);
if (senseAudioNonChatResult) return senseAudioNonChatResult;
const requestInit = {
...proxyDispatcher.requestInit,
method: 'POST', method: 'POST',
headers: call.headers, headers: call.headers,
body: JSON.stringify(call.body),
signal: controller.signal, signal: controller.signal,
redirect: 'error', redirect: 'error' as const,
};
let response = await fetch(call.url, {
...requestInit,
body: JSON.stringify(call.body),
}); });
const latencyMs = Date.now() - start; let latencyMs = Date.now() - start;
if (
!response.ok &&
call.retryBodyOnUnsupportedMaxTokens !== undefined
) {
let detailText = '';
try {
detailText = await response.text();
} catch {
detailText = '';
}
if (response.status === 400 && isUnsupportedMaxTokensError(detailText)) {
console.warn(
`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → retrying with max_completion_tokens`,
);
response = await fetch(call.url, {
...requestInit,
body: JSON.stringify(call.retryBodyOnUnsupportedMaxTokens),
});
latencyMs = Date.now() - start;
} else {
const redactedDetail = redactSecrets(detailText.slice(0, 240), [
input.apiKey,
]);
const kind = statusToKind(response.status, redactedDetail);
const detail =
redactedDetail ||
(response.status === 404
? 'HTTP 404 from provider; check the Base URL path.'
: '');
console.warn(
`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model}${response.status} in ${latencyMs}ms (${kind})${detail ? ` ${detail}` : ''}`,
);
return {
ok: false,
kind,
latencyMs,
model,
status: response.status,
detail,
};
}
}
if (response.ok) { if (response.ok) {
let data: unknown; let data: unknown;
let rawText = ''; let rawText = '';
@ -878,6 +1402,7 @@ export async function testProviderConnection(
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
input.signal?.removeEventListener('abort', abortFromParent); input.signal?.removeEventListener('abort', abortFromParent);
await proxyDispatcher?.close();
} }
} }
@ -1067,7 +1592,13 @@ function attachAgentStreamHandlers(
child, child,
prompt, prompt,
cwd, cwd,
model: model ?? null, // Same substitution as the chat-run path in server.ts — adapters whose
// CLI rejects the synthetic 'default' (e.g. AMR / vela, which forces
// session/set_model before session/prompt) need the def's first
// concrete fallback id here too, otherwise Test connection deadlocks
// on the same `session/set_model must be called before session/prompt`
// error the chat-run path already handles.
model: resolveModelForAgent(def as never, model ?? null),
mcpServers: [], mcpServers: [],
send, send,
}); });
@ -1126,6 +1657,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: input.agentId, agentName: input.agentId,
detail: `Unknown agent id: ${input.agentId}`, detail: `Unknown agent id: ${input.agentId}`,
diagnostics: { phase: 'binary_resolution' },
}; };
} }
const configuredAgentEnv = agentCliEnvForAgent( const configuredAgentEnv = agentCliEnvForAgent(
@ -1141,6 +1673,7 @@ async function testAgentConnectionInternal(
latencyMs: Date.now() - start, latencyMs: Date.now() - start,
model, model,
agentName: def.name, agentName: def.name,
diagnostics: { phase: 'binary_resolution' },
}; };
} }
@ -1152,7 +1685,37 @@ async function testAgentConnectionInternal(
let abortHandler: (() => void) | null = null; let abortHandler: (() => void) | null = null;
const sink = createAgentSink(); const sink = createAgentSink();
const resultFromAgentText = (text: string): ConnectionTestResponse => { // Phase tracker for structured diagnostics (#2248). The order matches
// the lifecycle: binary_resolution → spawn → connection_smoke_test →
// output_parse. Each result helper below stamps the *current* phase
// into the response so consumers don't have to scrape `detail` to
// know how far the test got. Phase is mutated at the points where
// the daemon meaningfully advances (just before spawn, when the
// child first produces stdout, etc.) — not on every event.
let phase: ConnectionTestPhase = 'binary_resolution';
const buildDiagnostics = (
overrides: Partial<ConnectionTestDiagnostics> = {},
): ConnectionTestDiagnostics => {
const rawStderr = sink.getStderrTail().trim();
const rawStdout = sink.getRawStdoutTail().trim();
// `exactOptionalPropertyTypes: true` means we can't pass `undefined`
// to an optional field directly — conditionally spread instead so
// empty values just don't appear in the response.
return {
phase,
...(executableResolution.launchPath
? { binaryPath: executableResolution.launchPath }
: {}),
...(rawStderr ? { stderrTail: redactSecrets(rawStderr) } : {}),
...(rawStdout ? { stdoutTail: redactSecrets(rawStdout) } : {}),
...overrides,
};
};
const resultFromAgentText = (
text: string,
exit?: { code: number | null; signal: NodeJS.Signals | null },
): ConnectionTestResponse => {
const latencyMs = Date.now() - start; const latencyMs = Date.now() - start;
const rawSample = truncateSample(text); const rawSample = truncateSample(text);
const sample = redactSecrets(rawSample); const sample = redactSecrets(rawSample);
@ -1168,6 +1731,10 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail, detail,
diagnostics: buildDiagnostics({
phase: 'output_parse',
...(exit ? { exitCode: exit.code, signal: exit.signal } : {}),
}),
}; };
} }
if (!isSmokeOkReply(text)) { if (!isSmokeOkReply(text)) {
@ -1176,6 +1743,14 @@ async function testAgentConnectionInternal(
); );
} }
console.log(`[test:agent] ${def.name} → ok in ${(latencyMs / 1000).toFixed(1)}s`); console.log(`[test:agent] ${def.name} → ok in ${(latencyMs / 1000).toFixed(1)}s`);
// resultFromChildExit can route ACP forced shutdown (code === null,
// signal === 'SIGTERM' + acpCleanCompletion) through this success
// helper. Hard-coding `exitCode: 0` would silently overwrite the
// SIGTERM signal and violate the raw code/signal contract in
// packages/contracts/src/api/connectionTest.ts. Pass through the
// real `winner.code` / `winner.signal` when the caller has them and
// only synthesize `exitCode: 0` when no exit context is available
// (theoretical text-without-exit path).
return { return {
ok: true, ok: true,
kind: 'success', kind: 'success',
@ -1183,6 +1758,11 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
sample, sample,
diagnostics: buildDiagnostics(
exit
? { phase: 'connection_smoke_test', exitCode: exit.code, signal: exit.signal }
: { phase: 'connection_smoke_test', exitCode: 0 },
),
}; };
}; };
@ -1201,6 +1781,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(), detail: auth.message ?? cursorAuthGuidance(),
diagnostics: buildDiagnostics(),
}; };
} }
if (detail && isLikelyModelErrorText(detail)) { if (detail && isLikelyModelErrorText(detail)) {
@ -1214,6 +1795,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail, detail,
diagnostics: buildDiagnostics({ phase: 'output_parse' }),
}; };
} }
console.warn( console.warn(
@ -1226,6 +1808,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail, detail,
diagnostics: buildDiagnostics(),
}; };
}; };
@ -1240,6 +1823,7 @@ async function testAgentConnectionInternal(
latencyMs, latencyMs,
model, model,
agentName: def.name, agentName: def.name,
diagnostics: buildDiagnostics(),
}; };
}; };
@ -1255,6 +1839,10 @@ async function testAgentConnectionInternal(
); );
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
// buildArgs runs *after* binary resolution but *before* spawn, so
// phase is still 'binary_resolution' here. Stamp diagnostics so the
// contract advertised in packages/contracts/src/api/connectionTest.ts
// ("Always set on local agent test responses") actually holds.
return { return {
ok: false, ok: false,
kind: 'agent_spawn_failed', kind: 'agent_spawn_failed',
@ -1262,6 +1850,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: redactSecrets(detail), detail: redactSecrets(detail),
diagnostics: buildDiagnostics(),
}; };
} }
const stdinMode = const stdinMode =
@ -1277,6 +1866,17 @@ async function testAgentConnectionInternal(
const env = applyAgentLaunchEnv(baseEnv, executableResolution); const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env); const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
if (auth?.status === 'missing') { if (auth?.status === 'missing') {
// Preflight auth probe runs after binary resolution but before the
// smoke spawn — phase is still 'binary_resolution'. The smoke
// sink is empty here (no spawn happened), so the probe itself is
// the only source of stderr/stdout/exit context. Fold what the
// probe captured into the diagnostics block; `...overrides` in
// buildDiagnostics() lets these win over the empty sink tails.
const probeOverrides: Partial<ConnectionTestDiagnostics> = {};
if (auth.stdoutTail) probeOverrides.stdoutTail = redactSecrets(auth.stdoutTail);
if (auth.stderrTail) probeOverrides.stderrTail = redactSecrets(auth.stderrTail);
if (auth.exitCode !== undefined) probeOverrides.exitCode = auth.exitCode;
if (auth.signal !== undefined) probeOverrides.signal = auth.signal;
return { return {
ok: false, ok: false,
kind: 'agent_auth_required', kind: 'agent_auth_required',
@ -1284,6 +1884,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(), detail: auth.message ?? cursorAuthGuidance(),
diagnostics: buildDiagnostics(probeOverrides),
}; };
} }
const invocation = createCommandInvocation({ const invocation = createCommandInvocation({
@ -1291,6 +1892,12 @@ async function testAgentConnectionInternal(
args, args,
env, env,
}); });
// We are about to hand off to child_process.spawn(). Any failure
// from here on (ENOENT, bad argv, non-zero exit) belongs to the
// 'spawn' phase rather than 'binary_resolution', so flip the tracker
// *before* spawning. resultFromAgentText flips it again to
// 'connection_smoke_test' / 'output_parse' once we get text out.
phase = 'spawn';
child = spawn(invocation.command, invocation.args, { child = spawn(invocation.command, invocation.args, {
env, env,
stdio: [stdinMode, 'pipe', 'pipe'], stdio: [stdinMode, 'pipe', 'pipe'],
@ -1344,6 +1951,9 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: `${detail}${guidance}`, detail: `${detail}${guidance}`,
diagnostics: buildDiagnostics({
phase: isMissing ? 'binary_resolution' : 'spawn',
}),
}; };
} }
@ -1373,10 +1983,11 @@ async function testAgentConnectionInternal(
(winner.code === 0 && !winner.signal) || acpForcedShutdown; (winner.code === 0 && !winner.signal) || acpForcedShutdown;
if (buffered) { if (buffered) {
const rawSample = truncateSample(buffered); const rawSample = truncateSample(buffered);
const exitInfo = { code: winner.code, signal: winner.signal };
if (rawSample && isLikelyModelErrorText(rawSample)) { if (rawSample && isLikelyModelErrorText(rawSample)) {
return resultFromAgentText(buffered); return resultFromAgentText(buffered, exitInfo);
} }
if (exitedCleanly) return resultFromAgentText(buffered); if (exitedCleanly) return resultFromAgentText(buffered, exitInfo);
} }
const stderrTail = sink.getStderrTail().trim(); const stderrTail = sink.getStderrTail().trim();
const rawStdoutTail = sink.getRawStdoutTail().trim(); const rawStdoutTail = sink.getRawStdoutTail().trim();
@ -1401,6 +2012,11 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(), detail: auth.message ?? cursorAuthGuidance(),
diagnostics: buildDiagnostics({
phase: 'connection_smoke_test',
exitCode: winner.code,
signal: winner.signal,
}),
}; };
} }
const claudeDiagnostic = diagnoseClaudeCliFailure({ const claudeDiagnostic = diagnoseClaudeCliFailure({
@ -1422,6 +2038,11 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: claudeDiagnostic.detail, detail: claudeDiagnostic.detail,
diagnostics: buildDiagnostics({
phase: 'spawn',
exitCode: winner.code,
signal: winner.signal,
}),
}; };
} }
const detail = redactSecrets( const detail = redactSecrets(
@ -1446,6 +2067,11 @@ async function testAgentConnectionInternal(
agentName: def.name, agentName: def.name,
detail: detail:
`${detail || 'Agent exited without producing assistant text'}${guidance}`, `${detail || 'Agent exited without producing assistant text'}${guidance}`,
diagnostics: buildDiagnostics({
phase: buffered ? 'output_parse' : 'spawn',
exitCode: winner.code,
signal: winner.signal,
}),
}; };
}; };
@ -1502,6 +2128,10 @@ async function testAgentConnectionInternal(
return resultFromChildExit(winner); return resultFromChildExit(winner);
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
// Outer catch — the failure may have happened at any phase between
// binary_resolution and output_parse, so stamp the current phase as
// observed. buildDiagnostics is defined in the enclosing scope and
// is safe to call here.
return { return {
ok: false, ok: false,
kind: 'agent_spawn_failed', kind: 'agent_spawn_failed',
@ -1509,6 +2139,7 @@ async function testAgentConnectionInternal(
model, model,
agentName: def.name, agentName: def.name,
detail: redactSecrets(detail), detail: redactSecrets(detail),
diagnostics: buildDiagnostics(),
}; };
} finally { } finally {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);

View file

@ -589,7 +589,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
} }
}); });
app.get('/api/connectors/:connectorId', async (req: Request, res: Response) => { app.get('/api/connectors/:connectorId', async (req: Request<{ connectorId: string }>, res: Response) => {
try { try {
const connectorId = req.params.connectorId; const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
@ -625,7 +625,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
} }
}); });
app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request, res: Response) => { app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
try { try {
const connectorId = req.params.connectorId; const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
@ -653,7 +653,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
} }
}); });
app.get('/api/connectors/oauth/callback/:connectorId', async (req: Request, res: Response) => { app.get('/api/connectors/oauth/callback/:connectorId', async (req: Request<{ connectorId: string }>, res: Response) => {
try { try {
const connectorId = req.params.connectorId; const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
@ -674,7 +674,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
} }
}); });
app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request, res: Response) => { app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
try { try {
const connectorId = req.params.connectorId; const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
@ -684,7 +684,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
} }
}); });
app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request, res: Response) => { app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
try { try {
const connectorId = req.params.connectorId; const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required'); if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');

View file

@ -58,6 +58,35 @@ function isMissingOrExpiredComposioOAuthState(error: unknown): boolean {
&& error.message === 'Composio OAuth state is missing or expired'; && error.message === 'Composio OAuth state is missing or expired';
} }
function boundedJsonValueIncludesAuthStaleSignal(value: BoundedJsonValue | undefined): boolean {
if (value === undefined || value === null) return false;
if (typeof value === 'number') return value === 401;
if (typeof value === 'boolean') return false;
if (typeof value === 'string') {
const normalized = value.toLowerCase();
return normalized === '401'
|| /\bbad credentials\b/.test(normalized)
|| /\bunauthori[sz]ed\b/.test(normalized)
|| /\binvalid (?:access )?token\b/.test(normalized)
|| /\btoken (?:is )?expired\b/.test(normalized)
|| /\bexpired (?:access )?token\b/.test(normalized);
}
if (Array.isArray(value)) return value.some(boundedJsonValueIncludesAuthStaleSignal);
return Object.values(value).some(boundedJsonValueIncludesAuthStaleSignal);
}
function isConnectorAuthStaleError(error: unknown, request: Pick<ConnectorExecuteRequest, 'connectorId' | 'toolName'>): boolean {
if (!(error instanceof ConnectorServiceError) || error.code !== 'CONNECTOR_EXECUTION_FAILED') return false;
const details = error.details;
return details?.connectorId === request.connectorId
&& details.toolName === request.toolName
&& boundedJsonValueIncludesAuthStaleSignal(details.error);
}
function connectorAuthExpiredMessage(definition: ConnectorCatalogDefinition): string {
return `${definition.name} authorization expired. Reconnect ${definition.name}.`;
}
function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean { function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean {
return credential?.credentials.provider === 'composio' return credential?.credentials.provider === 'composio'
&& credential.credentials.providerConnectionId === providerConnectionId; && credential.credentials.providerConnectionId === providerConnectionId;
@ -421,6 +450,11 @@ export class ConnectorStatusService {
return cloneStatus(next); return cloneStatus(next);
} }
markAuthenticationExpired(definition: ConnectorCatalogDefinition, lastError: string, accountLabel?: string): ConnectorConnectionStatus {
this.credentialStore?.delete(definition.id);
return this.setError(definition, lastError, accountLabel);
}
clear(connectorId: string): void { clear(connectorId: string): void {
this.statuses.delete(connectorId); this.statuses.delete(connectorId);
} }
@ -776,7 +810,15 @@ export class ConnectorService {
this.enforceRunLimits(context); this.enforceRunLimits(context);
const providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool); let providerOutput: BoundedJsonObject;
try {
providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool);
} catch (error) {
if (isConnectorAuthStaleError(error, request)) {
this.statusService.markAuthenticationExpired(definition, connectorAuthExpiredMessage(definition), connector.accountLabel);
}
throw error;
}
const protectedOutput = protectConnectorOutput(providerOutput); const protectedOutput = protectConnectorOutput(providerOutput);
const output = protectedOutput.output; const output = protectedOutput.output;
const outputSummary = summarizeConnectorOutput(output); const outputSummary = summarizeConnectorOutput(output);

View file

@ -28,9 +28,11 @@
// source root so an environment that puts `skills/` itself behind a // source root so an environment that puts `skills/` itself behind a
// symlink (e.g. a content-addressable mount) is followed correctly. // symlink (e.g. a content-addressable mount) is followed correctly.
import { createReadStream, createWriteStream } from 'node:fs';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { cp, lstat, rm, stat } from 'node:fs/promises'; import { chmod, cp, lstat, mkdir, readdir, rm, stat, utimes } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { pipeline } from 'node:stream/promises';
export const SKILLS_CWD_ALIAS = '.od-skills'; export const SKILLS_CWD_ALIAS = '.od-skills';
@ -52,6 +54,46 @@ export function skillCwdAliasSegment(dir: string): string {
return `${folder}-${digest}`; return `${folder}-${digest}`;
} }
// copy_file_range(2) — used by fs.cp under the hood — is rejected with
// these errno codes when source and destination live on different
// filesystems (commonly EXDEV; a container image layer copied onto a
// ZFS/overlay bind mount surfaces EPERM). Node doesn't fall back to a
// userspace copy on any of them, so we do.
const RECOVERABLE_COPY_CODES = new Set(['EPERM', 'EXDEV', 'ENOTSUP', 'EOPNOTSUPP']);
type SkillCopyFn = (
source: string,
destination: string,
options: { recursive: boolean; dereference: boolean; preserveTimestamps: boolean },
) => Promise<void>;
// Recursive copy that mirrors `cp({ dereference: true })` without going
// through copy_file_range. `stat()` (not `lstat`) follows symlinks, so
// every staged entry lands as a real directory or regular file — keeping
// `.od-skills/` a self-contained write barrier even on the fallback path.
async function copyTreeDereferenced(srcDir: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true });
for (const entry of await readdir(srcDir, { withFileTypes: true })) {
const from = path.join(srcDir, entry.name);
const to = path.join(destDir, entry.name);
const entryStat = await stat(from);
if (entryStat.isDirectory()) {
await copyTreeDereferenced(from, to);
} else if (entryStat.isFile()) {
await pipeline(createReadStream(from), createWriteStream(to));
// createWriteStream opens the destination with the default 0644, so
// restore the source's permission bits (notably the exec bit on
// skill helper scripts) and mtime — `fs.cp` preserves these, and
// skills shell out to staged scripts. Mask to 0o777 so the
// agent-writable staging copy never inherits setuid/setgid/sticky.
await chmod(to, entryStat.mode & 0o777);
await utimes(to, entryStat.atime, entryStat.mtime);
}
// Sockets, FIFOs, and devices can't appear in a sane skill folder and
// copying them would hang or fail — skip them.
}
}
/** /**
* Copy `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can * Copy `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can
* reach skill side files via a cwd-relative path. Idempotent and * reach skill side files via a cwd-relative path. Idempotent and
@ -68,6 +110,11 @@ export async function stageActiveSkill(
folderName: string, folderName: string,
sourceDir: string, sourceDir: string,
log: SkillStagingLogger = () => {}, log: SkillStagingLogger = () => {},
// Seam for tests: the real copy_file_range EPERM only reproduces on
// specific cross-filesystem mounts (ZFS/overlay), so tests inject a
// copy that rejects with a synthetic code to drive the fallback path.
nativeCopy: SkillCopyFn = (source, destination, options) =>
cp(source, destination, options),
): Promise<SkillStagingResult> { ): Promise<SkillStagingResult> {
if (!cwd) { if (!cwd) {
return { staged: false, reason: 'no project cwd' }; return { staged: false, reason: 'no project cwd' };
@ -123,16 +170,26 @@ export async function stageActiveSkill(
// reflected and a partially-failed previous run cannot leave junk // reflected and a partially-failed previous run cannot leave junk
// behind. // behind.
await rm(stagedPath, { recursive: true, force: true }); await rm(stagedPath, { recursive: true, force: true });
await cp(sourceDir, stagedPath, { try {
recursive: true, await nativeCopy(sourceDir, stagedPath, {
// Resolve every symlink we find inside the skill so the staged recursive: true,
// copy is a fully self-contained set of regular files. This is // Resolve every symlink we find inside the skill so the staged
// what makes the copy a true write barrier — no entry under // copy is a fully self-contained set of regular files. This is
// `.od-skills/...` can resolve back to a real file outside the // what makes the copy a true write barrier — no entry under
// project cwd. // `.od-skills/...` can resolve back to a real file outside the
dereference: true, // project cwd.
preserveTimestamps: true, dereference: true,
}); preserveTimestamps: true,
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (!RECOVERABLE_COPY_CODES.has(code)) throw err;
log(
`[od] skill-stage: native copy failed (${code}); retrying with stream copy`,
);
await rm(stagedPath, { recursive: true, force: true });
await copyTreeDereferenced(sourceDir, stagedPath);
}
return { staged: true, stagedPath }; return { staged: true, stagedPath };
} catch (err) { } catch (err) {
log(`[od] skill-stage failed: ${(err as Error).message}`); log(`[od] skill-stage failed: ${(err as Error).message}`);

View file

@ -94,6 +94,7 @@ function migrate(db: SqliteDb): void {
attachments_json TEXT, attachments_json TEXT,
produced_files_json TEXT, produced_files_json TEXT,
feedback_json TEXT, feedback_json TEXT,
pre_turn_file_names_json TEXT,
started_at INTEGER, started_at INTEGER,
ended_at INTEGER, ended_at INTEGER,
position INTEGER NOT NULL, position INTEGER NOT NULL,
@ -115,6 +116,7 @@ function migrate(db: SqliteDb): void {
text TEXT NOT NULL, text TEXT NOT NULL,
position_json TEXT NOT NULL, position_json TEXT NOT NULL,
html_hint TEXT NOT NULL, html_hint TEXT NOT NULL,
style_json TEXT,
note TEXT NOT NULL, note TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
@ -136,6 +138,12 @@ function migrate(db: SqliteDb): void {
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS tabs_state (
project_id TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tabs_project CREATE INDEX IF NOT EXISTS idx_tabs_project
ON tabs(project_id, position); ON tabs(project_id, position);
@ -228,6 +236,9 @@ function migrate(db: SqliteDb): void {
if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) { if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`); db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
} }
if (!messageCols.some((c: DbRow) => c.name === 'pre_turn_file_names_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN pre_turn_file_names_json TEXT`);
}
const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[]; const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[];
if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) { if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) {
db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`); db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`);
@ -243,6 +254,9 @@ function migrate(db: SqliteDb): void {
if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) { if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`); db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`);
} }
if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) {
db.exec(`ALTER TABLE preview_comments ADD COLUMN style_json TEXT`);
}
const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[]; const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[];
if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { if (!deploymentCols.some((c: DbRow) => c.name === 'status')) {
db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`);
@ -874,6 +888,7 @@ export function listMessages(db: SqliteDb, conversationId: string) {
comment_attachments_json AS commentAttachmentsJson, comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson, produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson, feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position position
FROM messages FROM messages
@ -895,7 +910,9 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
SET role = ?, content = ?, agent_id = ?, agent_name = ?, SET role = ?, content = ?, agent_id = ?, agent_name = ?,
run_id = ?, run_status = ?, last_run_event_id = ?, run_id = ?, run_status = ?, last_run_event_id = ?,
events_json = ?, attachments_json = ?, comment_attachments_json = ?, events_json = ?, attachments_json = ?, comment_attachments_json = ?,
produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ? produced_files_json = ?, feedback_json = ?,
pre_turn_file_names_json = ?,
started_at = ?, ended_at = ?
WHERE id = ?`, WHERE id = ?`,
).run( ).run(
m.role, m.role,
@ -910,6 +927,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null, m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null, m.startedAt ?? null,
m.endedAt ?? null, m.endedAt ?? null,
m.id, m.id,
@ -921,17 +939,18 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
) )
.get(conversationId) as DbRow | undefined; .get(conversationId) as DbRow | undefined;
const position = (max?.m ?? -1) + 1; const position = (max?.m ?? -1) + 1;
// 18 values: id, conversation_id, role, content, agent_id, agent_name, // 19 values: id, conversation_id, role, content, agent_id, agent_name,
// run_id, run_status, last_run_event_id, events_json, attachments_json, // run_id, run_status, last_run_event_id, events_json, attachments_json,
// comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at, // comment_attachments_json, produced_files_json, feedback_json,
// position, created_at. // pre_turn_file_names_json, started_at, ended_at, position, created_at.
db.prepare( db.prepare(
`INSERT INTO messages `INSERT INTO messages
(id, conversation_id, role, content, agent_id, agent_name, (id, conversation_id, role, content, agent_id, agent_name,
run_id, run_status, last_run_event_id, events_json, run_id, run_status, last_run_event_id, events_json,
attachments_json, comment_attachments_json, produced_files_json, attachments_json, comment_attachments_json, produced_files_json,
feedback_json, started_at, ended_at, position, created_at) feedback_json, pre_turn_file_names_json,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
m.id, m.id,
conversationId, conversationId,
@ -947,6 +966,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null, m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null, m.startedAt ?? null,
m.endedAt ?? null, m.endedAt ?? null,
position, position,
@ -968,6 +988,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
comment_attachments_json AS commentAttachmentsJson, comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson, produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson, feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position position
FROM messages WHERE id = ?`, FROM messages WHERE id = ?`,
@ -1042,7 +1063,7 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio
file_path AS filePath, element_id AS elementId, selector, label, file_path AS filePath, element_id AS elementId, selector, label,
text, position_json AS positionJson, html_hint AS htmlHint, text, position_json AS positionJson, html_hint AS htmlHint,
selection_kind AS selectionKind, member_count AS memberCount, selection_kind AS selectionKind, member_count AS memberCount,
pod_members_json AS podMembersJson, pod_members_json AS podMembersJson, style_json AS styleJson,
note, status, created_at AS createdAt, updated_at AS updatedAt note, status, created_at AS createdAt, updated_at AS updatedAt
FROM preview_comments FROM preview_comments
WHERE project_id = ? AND conversation_id = ? WHERE project_id = ? AND conversation_id = ?
@ -1065,6 +1086,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
const position = normalizePosition(target.position); const position = normalizePosition(target.position);
const selectionKind = target.selectionKind === 'pod' ? 'pod' : 'element'; const selectionKind = target.selectionKind === 'pod' ? 'pod' : 'element';
const podMembers = selectionKind === 'pod' ? normalizePodMembers(target.podMembers) : []; const podMembers = selectionKind === 'pod' ? normalizePodMembers(target.podMembers) : [];
const style = normalizeAnnotationStyle(target.style);
const memberCount = selectionKind === 'pod' const memberCount = selectionKind === 'pod'
? (podMembers.length > 0 ? (podMembers.length > 0
? podMembers.length ? podMembers.length
@ -1086,8 +1108,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
`INSERT INTO preview_comments `INSERT INTO preview_comments
(id, project_id, conversation_id, file_path, element_id, selector, label, (id, project_id, conversation_id, file_path, element_id, selector, label,
text, position_json, html_hint, selection_kind, member_count, pod_members_json, text, position_json, html_hint, selection_kind, member_count, pod_members_json,
note, status, created_at, updated_at) style_json, note, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET
selector = excluded.selector, selector = excluded.selector,
label = excluded.label, label = excluded.label,
@ -1097,6 +1119,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
selection_kind = excluded.selection_kind, selection_kind = excluded.selection_kind,
member_count = excluded.member_count, member_count = excluded.member_count,
pod_members_json = excluded.pod_members_json, pod_members_json = excluded.pod_members_json,
style_json = excluded.style_json,
note = excluded.note, note = excluded.note,
status = 'open', status = 'open',
updated_at = excluded.updated_at`, updated_at = excluded.updated_at`,
@ -1114,6 +1137,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
selectionKind, selectionKind,
selectionKind === 'pod' ? memberCount : null, selectionKind === 'pod' ? memberCount : null,
selectionKind === 'pod' ? JSON.stringify(podMembers) : null, selectionKind === 'pod' ? JSON.stringify(podMembers) : null,
style ? JSON.stringify(style) : null,
note, note,
'open', 'open',
createdAt, createdAt,
@ -1150,7 +1174,7 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri
file_path AS filePath, element_id AS elementId, selector, label, file_path AS filePath, element_id AS elementId, selector, label,
text, position_json AS positionJson, html_hint AS htmlHint, text, position_json AS positionJson, html_hint AS htmlHint,
selection_kind AS selectionKind, member_count AS memberCount, selection_kind AS selectionKind, member_count AS memberCount,
pod_members_json AS podMembersJson, pod_members_json AS podMembersJson, style_json AS styleJson,
note, status, created_at AS createdAt, updated_at AS updatedAt note, status, created_at AS createdAt, updated_at AS updatedAt
FROM preview_comments FROM preview_comments
WHERE id = ? AND project_id = ? AND conversation_id = ?`, WHERE id = ? AND project_id = ? AND conversation_id = ?`,
@ -1173,6 +1197,7 @@ function normalizePreviewComment(row: DbRow) {
text: row.text, text: row.text,
position: parseJsonOrUndef(row.positionJson) ?? { x: 0, y: 0, width: 0, height: 0 }, position: parseJsonOrUndef(row.positionJson) ?? { x: 0, y: 0, width: 0, height: 0 },
htmlHint: row.htmlHint, htmlHint: row.htmlHint,
style: normalizeAnnotationStyle(parseJsonOrUndef(row.styleJson)),
selectionKind: row.selectionKind === 'pod' ? 'pod' : 'element', selectionKind: row.selectionKind === 'pod' ? 'pod' : 'element',
memberCount: memberCount:
normalizedPodMembers && normalizedPodMembers.length > 0 normalizedPodMembers && normalizedPodMembers.length > 0
@ -1214,11 +1239,40 @@ function normalizePodMembers(input: unknown) {
typeof member.htmlHint === 'string' typeof member.htmlHint === 'string'
? compactWhitespace(member.htmlHint).slice(0, 180) ? compactWhitespace(member.htmlHint).slice(0, 180)
: '', : '',
style: normalizeAnnotationStyle(member.style),
}; };
}) })
.filter(Boolean); .filter(Boolean);
} }
function normalizeAnnotationStyle(input: unknown) {
if (!input || typeof input !== 'object') return undefined;
const raw = input as DbRow;
const style: DbRow = {};
for (const key of ANNOTATION_STYLE_KEYS) {
const value = raw[key];
if (typeof value !== 'string') continue;
const trimmed = compactWhitespace(value);
if (trimmed) style[key] = trimmed.slice(0, 120);
}
return Object.keys(style).length > 0 ? style : undefined;
}
const ANNOTATION_STYLE_KEYS = [
'color',
'backgroundColor',
'fontSize',
'fontWeight',
'lineHeight',
'textAlign',
'fontFamily',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'borderRadius',
] as const;
function compactWhitespace(value: string): string { function compactWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim(); return value.replace(/\s+/g, ' ').trim();
} }
@ -1256,6 +1310,7 @@ function normalizeMessage(row: DbRow) {
commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson), commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
producedFiles: parseJsonOrUndef(row.producedFilesJson), producedFiles: parseJsonOrUndef(row.producedFilesJson),
feedback: parseJsonOrUndef(row.feedbackJson), feedback: parseJsonOrUndef(row.feedbackJson),
preTurnFileNames: parseJsonOrUndef(row.preTurnFileNamesJson),
createdAt: row.createdAt ?? undefined, createdAt: row.createdAt ?? undefined,
startedAt: row.startedAt ?? undefined, startedAt: row.startedAt ?? undefined,
endedAt: row.endedAt ?? undefined, endedAt: row.endedAt ?? undefined,
@ -1488,15 +1543,24 @@ export function listTabs(db: SqliteDb, projectId: string) {
FROM tabs WHERE project_id = ? ORDER BY position ASC`, FROM tabs WHERE project_id = ? ORDER BY position ASC`,
) )
.all(projectId) as DbRow[]; .all(projectId) as DbRow[];
const state = db
.prepare(`SELECT project_id FROM tabs_state WHERE project_id = ? LIMIT 1`)
.get(projectId) as DbRow | undefined;
const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null; const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null;
return { return {
tabs: (rows as DbRow[]).map((r: DbRow) => r.name), tabs: (rows as DbRow[]).map((r: DbRow) => r.name),
active: active ? active.name : null, active: active ? active.name : null,
hasSavedState: rows.length > 0 || Boolean(state),
}; };
} }
export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) { export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) {
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare(
`INSERT INTO tabs_state (project_id, updated_at)
VALUES (?, ?)
ON CONFLICT(project_id) DO UPDATE SET updated_at = excluded.updated_at`,
).run(projectId, Date.now());
db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId); db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
const ins = db.prepare( const ins = db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active) `INSERT INTO tabs (project_id, name, position, is_active)

View file

@ -89,7 +89,7 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps
PROJECTS_DIR, PROJECTS_DIR,
req.params.id, req.params.id,
fileName, fileName,
{ metadata: deployProject?.metadata }, { metadata: deployProject?.metadata, includeProjectFiles: true },
); );
const project = getProject(db, req.params.id); const project = getProject(db, req.params.id);
const cloudflarePagesProjectName = const cloudflarePagesProjectName =
@ -171,7 +171,7 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps
PROJECTS_DIR, PROJECTS_DIR,
req.params.id, req.params.id,
fileName, fileName,
{ metadata: preflightProject?.metadata, providerId }, { metadata: preflightProject?.metadata, providerId, includeProjectFiles: true },
); );
res.json(body); res.json(body);
} catch (err: any) { } catch (err: any) {

View file

@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { hash as blake3Hash } from 'blake3-wasm'; import { hash as blake3Hash } from 'blake3-wasm';
import { readProjectFile, validateProjectPath } from './projects.js'; import { listFiles, readProjectFile, validateProjectPath } from './projects.js';
export const VERCEL_PROVIDER_ID = 'vercel-self'; export const VERCEL_PROVIDER_ID = 'vercel-self';
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages'; export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
@ -29,7 +29,12 @@ type CloudflarePagesConfigHints = {
}; };
type DeployFile = { file: string; data: Buffer | Uint8Array | string; contentType?: string; sourcePath?: string }; type DeployFile = { file: string; data: Buffer | Uint8Array | string; contentType?: string; sourcePath?: string };
type DeployFilePlan = { entryPath: string; html: string; files: DeployFile[]; missing: string[]; invalid: string[] }; type DeployFilePlan = { entryPath: string; html: string; files: DeployFile[]; missing: string[]; invalid: string[] };
type DeployOptions = { metadata?: unknown; hookScriptUrl?: string; providerId?: DeployProviderId }; type DeployOptions = {
metadata?: unknown;
hookScriptUrl?: string;
providerId?: DeployProviderId;
includeProjectFiles?: boolean;
};
type CloudflarePagesDeploySelection = { zoneId: string; zoneName: string; domainPrefix: string; hostname: string }; type CloudflarePagesDeploySelection = { zoneId: string; zoneName: string; domainPrefix: string; hostname: string };
type CloudflareDnsRecord = JsonObject & { id?: string; type?: string; name?: string; content?: string; comment?: string }; type CloudflareDnsRecord = JsonObject & { id?: string; type?: string; name?: string; content?: string; comment?: string };
type DeployLinkStatus = 'ready' | 'protected' | 'failed' | 'link-delayed'; type DeployLinkStatus = 'ready' | 'protected' | 'failed' | 'link-delayed';
@ -318,6 +323,14 @@ export async function buildDeployFilePlan(projectsRoot: string, projectId: strin
} }
} }
if (options.includeProjectFiles) {
await addVisibleProjectFilesToDeployPlan(files, {
projectsRoot,
projectId,
metadata: options.metadata,
});
}
return { return {
entryPath, entryPath,
html, html,
@ -341,6 +354,37 @@ export async function buildDeployFileSet(projectsRoot: string, projectId: string
return plan.files; return plan.files;
} }
async function addVisibleProjectFilesToDeployPlan(
files: Map<string, DeployFile>,
input: { projectsRoot: string; projectId: string; metadata?: unknown },
) {
if (isLinkedFolderProject(input.metadata)) return;
const projectFiles = await listFiles(input.projectsRoot, input.projectId, { metadata: input.metadata });
for (const item of projectFiles) {
if (!item?.name || files.has(item.name)) continue;
const safePath = validateProjectPath(item.name);
// The selected entry is already mapped to provider-root index.html. Keep
// the original root index reserved so choosing index-v1.html does not get
// overwritten by the old launcher at the same deploy path.
if (safePath === 'index.html') continue;
const projectFile = await readProjectFile(input.projectsRoot, input.projectId, safePath, input.metadata);
files.set(safePath, {
file: safePath,
data: projectFile.buffer,
contentType: projectFile.mime,
sourcePath: safePath,
});
}
}
function isLinkedFolderProject(metadata: unknown) {
return Boolean(
metadata
&& typeof metadata === 'object'
&& typeof (metadata as { baseDir?: unknown }).baseDir === 'string',
);
}
export async function deployToVercel({ config, files, projectId }: { config: DeployConfig; files: DeployFile[]; projectId: string }) { export async function deployToVercel({ config, files, projectId }: { config: DeployConfig; files: DeployFile[]; projectId: string }) {
if (!config?.token) { if (!config?.token) {
throw new DeployError('Vercel token is required.', 400); throw new DeployError('Vercel token is required.', 400);

View file

@ -0,0 +1,54 @@
// Pure argument parser for `od design-systems rename <id> --title <new>`.
// Kept out of cli.ts (a top-level dispatch script that runs on import) so it
// can be unit-tested directly, mirroring research/cli-args.ts.
//
// Accepts the new name either as a `--title <value>` / `--title=<value>` flag
// or as the trailing positional(s) after the id (so `rename <id> "New name"`
// works). String flags that take a separate value (`--daemon-url <url>`, etc.)
// have that value skipped so it is never mistaken for the id or title.
export interface DesignSystemRenameArgs {
id: string;
title: string;
}
const STRING_FLAGS_WITH_VALUE = new Set(['daemon-url', 'query', 'tag', 'title']);
// A separate flag value must be a real token, not the next flag. Without this
// guard, `--title --json` would read "--json" as the title and rename the
// system to a flag name. A leading dash means the user must use the
// `--title=<value>` form for a title that genuinely starts with a dash.
function isFlagValue(token: string | undefined): token is string {
return token !== undefined && !token.startsWith('-');
}
export function parseDesignSystemRenameArgs(args: string[]): DesignSystemRenameArgs | null {
let flagTitle: string | undefined;
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
const inlineValue = eq >= 0 ? arg.slice(eq + 1) : undefined;
if (key === 'title') {
if (inlineValue !== undefined) {
flagTitle = inlineValue;
} else if (isFlagValue(args[i + 1])) {
flagTitle = args[++i];
}
// else: `--title` with no real value -> leave it unset so the missing
// title fails usage validation below instead of swallowing a flag.
} else if (inlineValue === undefined && STRING_FLAGS_WITH_VALUE.has(key) && isFlagValue(args[i + 1])) {
i++; // consume the separate flag value so it is not read as a positional
}
continue;
}
if (arg.startsWith('-')) continue; // short flag, no positional
positionals.push(arg);
}
const id = positionals[0];
const title = (flagTitle ?? positionals.slice(1).join(' ') ?? '').trim();
if (!id || !title) return null;
return { id, title };
}

View file

@ -0,0 +1,15 @@
// Help surface for `od design-systems`. Kept pure and separate from cli.ts so a
// test can assert the advertised subcommands without spawning the CLI or
// stubbing process.exit / console.log.
export const DESIGN_SYSTEMS_USAGE = `Usage:
od design-systems list List design systems.
od design-systems show <id> Print one entry.
od design-systems rename <id> --title <new> Rename an editable design system.`;
// `help`, `--help`, and `-h` all route to the usage text above. Without the
// flag forms, `od design-systems --help` falls through to the generic library
// list, which only advertises `list` and `show` and never mentions `rename`.
export function isDesignSystemsHelpArg(arg: string | undefined): boolean {
return arg === 'help' || arg === '--help' || arg === '-h';
}

View file

@ -21,6 +21,7 @@ import {
import { parseFrontmatter } from './frontmatter.js'; import { parseFrontmatter } from './frontmatter.js';
import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js'; import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js';
import { extractSwiftColors } from './swift-colors.js';
export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio'; export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio';
export type DesignSystemSource = 'built-in' | 'installed' | 'user'; export type DesignSystemSource = 'built-in' | 'installed' | 'user';
@ -2823,6 +2824,28 @@ function extractSwatches(raw: string): string[] {
// Form B: "**Stripe Purple** (`#533afd`)" // Form B: "**Stripe Purple** (`#533afd`)"
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g; const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? ''); while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
// Form C: markdown table rows, e.g.
// | Window canvas | `--window-background` | `#1a1a1d` | base |
// Use the first cell that holds a hex as the value, and the first plain
// text cell (not the hex, not a `---` separator) as the name. Header and
// separator rows carry no hex, so they are skipped. Form A/B run first, so
// inline definitions still win in pickSwatchRow when a file mixes both.
const reC = /^[ \t]*\|(.+)\|[ \t]*$/gm;
while ((m = reC.exec(raw)) !== null) {
const cells = (m[1] ?? '').split('|').map((cell) => cell.trim());
const hexCell = cells.find((cell) => /#[0-9a-fA-F]{3,8}\b/.test(cell));
if (!hexCell) continue;
const hex = hexCell.match(/#[0-9a-fA-F]{3,8}/)?.[0] ?? '';
const nameCell = cells.find(
(cell) => cell.length > 0 && !/#[0-9a-fA-F]{3,8}/.test(cell) && !/^[-:\s]+$/.test(cell),
);
push(nameCell ?? '', hex);
}
// Form D: SwiftUI Color(...) declarations (HSB / RGB / white), converted to
// hex. Swift repos define palette tokens in source rather than CSS, so a
// captured ColorSystem.swift or a DESIGN.md that quotes it would otherwise
// yield no swatches. Inline hex forms above still win in pickSwatchRow.
for (const token of extractSwiftColors(raw)) push(token.name, token.hex);
if (colors.length === 0) return []; if (colors.length === 0) return [];
return pickSwatchRow(colors).values; return pickSwatchRow(colors).values;
} }

View file

@ -13,11 +13,12 @@ import {
import { import {
APP_KEYS, APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT, OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_MODES,
type SidecarStamp, type SidecarStamp,
} from '@open-design/sidecar-proto'; } from '@open-design/sidecar-proto';
import { import {
resolveLogFilePath, resolveLogFilePath,
resolveNamespaceRoot, resolveRuntimeNamespaceRoot,
type SidecarRuntimeContext, type SidecarRuntimeContext,
} from '@open-design/sidecar'; } from '@open-design/sidecar';
@ -48,10 +49,14 @@ export const STANDALONE_LAUNCH_WARNING =
function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | null): LogSource[] { function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | null): LogSource[] {
if (runtime == null) return []; if (runtime == null) return [];
const namespaceRoot = resolveNamespaceRoot({ // In packaged builds `runtime.base` is `<namespaceRoot>/runtime`, so the log
base: runtime.base, // tree lives a level UP at `<namespaceRoot>/logs`; `resolveRuntimeNamespaceRoot`
// accounts for that (a plain `resolveNamespaceRoot` here resolved every
// daemon/web log to an ENOENT phantom path and captured none of them).
const namespaceRoot = resolveRuntimeNamespaceRoot({
contract: OPEN_DESIGN_SIDECAR_CONTRACT, contract: OPEN_DESIGN_SIDECAR_CONTRACT,
namespace: runtime.namespace, runtime,
runtimeMode: SIDECAR_MODES.RUNTIME,
}); });
const apps = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP]; const apps = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
const sources: LogSource[] = []; const sources: LogSource[] = [];

View file

@ -95,7 +95,10 @@ function cloneVoiceOptions(voices: ElevenLabsVoiceOption[]): ElevenLabsVoiceOpti
export async function listElevenLabsVoiceOptions( export async function listElevenLabsVoiceOptions(
projectRoot: string, projectRoot: string,
options: { limit?: number } = {}, options: {
limit?: number;
requestInit?: Pick<RequestInit, 'dispatcher'>;
} = {},
): Promise<ElevenLabsVoiceOption[]> { ): Promise<ElevenLabsVoiceOption[]> {
const credentials = await resolveProviderConfig(projectRoot, 'elevenlabs'); const credentials = await resolveProviderConfig(projectRoot, 'elevenlabs');
if (!credentials.apiKey) { if (!credentials.apiKey) {
@ -122,6 +125,7 @@ export async function listElevenLabsVoiceOptions(
} }
const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, { const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, {
...options.requestInit,
method: 'GET', method: 'GET',
headers: { headers: {
'xi-api-key': credentials.apiKey, 'xi-api-key': credentials.apiKey,

View file

@ -41,6 +41,7 @@ import {
validateProjectPath, validateProjectPath,
} from './projects.js'; } from './projects.js';
import { exportProjectTranscript } from './transcript-export.js'; import { exportProjectTranscript } from './transcript-export.js';
import { googleGenerateContentUrl } from './google-models.js';
// Re-export the request/response types so existing daemon-internal // Re-export the request/response types so existing daemon-internal
// imports (and the route handler) keep their referenced names. The // imports (and the route handler) keep their referenced names. The
@ -595,9 +596,8 @@ function buildFinalizeProviderRequest(params: FinalizeProviderCallParams): Final
} }
if (params.protocol === 'google') { if (params.protocol === 'google') {
const clean = params.baseUrl.replace(/\/+$/, '');
return { return {
url: `${clean}/v1beta/models/${encodeURIComponent(params.model)}:generateContent`, url: googleGenerateContentUrl(params.baseUrl, params.model),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
'x-goog-api-key': params.apiKey, 'x-goog-api-key': params.apiKey,

View file

@ -0,0 +1,33 @@
export function googleGenerativeLanguageBaseUrl(baseUrl: string): string {
const url = new URL(baseUrl);
url.search = '';
url.hash = '';
const pathname = url.pathname
.replace(/\/+$/, '')
.replace(/\/v\d+(?:beta)?$/i, '');
url.pathname = pathname || '/';
return url.toString().replace(/\/+$/, '');
}
export function normalizeGoogleModelId(model: string): string {
const trimmed = model.trim();
return trimmed.startsWith('models/') ? trimmed.slice('models/'.length) : trimmed;
}
export function googleModelPathSegment(model: string): string {
return encodeURIComponent(normalizeGoogleModelId(model));
}
export function googleGenerateContentUrl(baseUrl: string, model: string): string {
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:generateContent`;
}
export function googleStreamGenerateContentUrl(baseUrl: string, model: string): string {
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:streamGenerateContent?alt=sse`;
}
export function googleProviderModelsUrl(baseUrl: string, apiKey: string): string {
const url = new URL(`${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models`);
url.searchParams.set('key', apiKey);
return url.toString();
}

View file

@ -0,0 +1,59 @@
import type { Express, Request, Response } from 'express';
import { createApiError } from '@open-design/contracts';
import { rawInput } from './parse.js';
import { sendApiError, sendJson, statusForError } from './response.js';
import { guardSameOrigin, type OriginContext } from './origin-guard.js';
import type { JsonRouteSpec } from './types.js';
export interface AdapterContext extends OriginContext {}
/**
* Identity function that pins a route spec's generic parameters at the
* definition site so callers do not have to repeat them. The returned spec
* is consumed by `mountJsonRoute` (live) and by tests (direct invocation of
* `route.parse` / `route.handle`).
*/
export function defineJsonRoute<Input, Output, Deps>(
spec: JsonRouteSpec<Input, Output, Deps>,
): JsonRouteSpec<Input, Output, Deps> {
return spec;
}
/**
* Mounts one JsonRouteSpec on an Express app. The Adapter is the only code
* here that knows about req/res; the route's parse and handle functions
* operate on `RouteInputContext` and `Deps` respectively, so they are unit
* testable without Express.
*/
export function mountJsonRoute<Input, Output, Deps>(
app: Express,
spec: JsonRouteSpec<Input, Output, Deps>,
deps: Deps,
adapter: AdapterContext,
): void {
app[spec.method](spec.path, async (req: Request, res: Response) => {
try {
if (spec.requireSameOrigin) {
const origin = guardSameOrigin(req, adapter);
if (!origin.ok) {
sendApiError(res, statusForError(origin.error), origin.error);
return;
}
}
const parsed = spec.parse(rawInput(req));
if (!parsed.ok) {
sendApiError(res, statusForError(parsed.error), parsed.error);
return;
}
const result = await spec.handle(parsed.value, deps);
if (!result.ok) {
sendApiError(res, statusForError(result.error), result.error);
return;
}
sendJson(res, spec.successStatus ?? 200, result.value);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
sendApiError(res, 500, createApiError('INTERNAL_ERROR', message));
}
});
}

View file

@ -0,0 +1,5 @@
export * from './types.js';
export * from './parse.js';
export * from './response.js';
export * from './origin-guard.js';
export * from './adapter.js';

View file

@ -0,0 +1,20 @@
import type { Request } from 'express';
import { createApiError } from '@open-design/contracts';
import { isLocalSameOrigin } from '../origin-validation.js';
import { err, ok, type Result } from './types.js';
export interface OriginContext {
resolvedPortRef: { current: number };
}
/**
* Adapter wrapper around `isLocalSameOrigin` that yields a `Result` so the
* HTTP Adapter can fold the origin decision into the same error-handling
* pipeline as parse/handle failures.
*/
export function guardSameOrigin(req: Request, origin: OriginContext): Result<void> {
if (isLocalSameOrigin(req, origin.resolvedPortRef.current)) {
return ok(undefined);
}
return err(createApiError('FORBIDDEN', 'cross-origin request rejected'));
}

View file

@ -0,0 +1,23 @@
import type { Request } from 'express';
import { createApiError, type ApiError } from '@open-design/contracts';
import type { RouteInputContext } from './types.js';
export function rawInput(req: Request): RouteInputContext {
return {
body: req.body,
query: (req.query ?? {}) as Record<string, unknown>,
params: (req.params ?? {}) as Record<string, string>,
};
}
export function validationError(
message: string,
issues: Array<{ path: string; message: string }> = [],
): ApiError {
if (issues.length === 0) {
return createApiError('BAD_REQUEST', message);
}
return createApiError('BAD_REQUEST', message, {
details: { kind: 'validation', issues } as unknown as NonNullable<ApiError['details']>,
});
}

View file

@ -0,0 +1,32 @@
import type { Response } from 'express';
import { createApiErrorResponse, type ApiError, type ApiErrorCode } from '@open-design/contracts';
export function sendJson(res: Response, status: number, body: unknown): void {
res.status(status).json(body);
}
export function sendApiError(res: Response, status: number, error: ApiError): void {
res.status(status).json(createApiErrorResponse(error));
}
const ERROR_STATUS_BY_CODE: Partial<Record<ApiErrorCode, number>> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
PAYLOAD_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
VALIDATION_FAILED: 422,
RATE_LIMITED: 429,
PROJECT_NOT_FOUND: 404,
FILE_NOT_FOUND: 404,
ARTIFACT_NOT_FOUND: 404,
INTERNAL_ERROR: 500,
AGENT_UNAVAILABLE: 503,
UPSTREAM_UNAVAILABLE: 502,
};
export function statusForError(error: ApiError): number {
return ERROR_STATUS_BY_CODE[error.code] ?? 500;
}

View file

@ -0,0 +1,32 @@
import type { ApiError } from '@open-design/contracts';
export type Result<T, E = ApiError> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T, E = ApiError>(value: T): Result<T, E> => ({ ok: true, value });
export const err = <T = never, E = ApiError>(error: E): Result<T, E> => ({ ok: false, error });
export interface RouteInputContext {
body: unknown;
query: Record<string, unknown>;
params: Record<string, string>;
}
export type InputParser<Input> = (raw: RouteInputContext) => Result<Input>;
export type Handler<Input, Output, Deps> = (
input: Input,
deps: Deps,
) => Promise<Result<Output>> | Result<Output>;
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
export interface JsonRouteSpec<Input, Output, Deps> {
method: HttpMethod;
path: string;
requireSameOrigin?: boolean;
parse: InputParser<Input>;
handle: Handler<Input, Output, Deps>;
successStatus?: number;
}

View file

@ -494,7 +494,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
// //
// See nexu-io/open-design#368 and the architecture lock at // See nexu-io/open-design#368 and the architecture lock at
// https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218. // https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218.
app.get('/api/projects/:id/export/*', async (req, res) => { app.get('/api/projects/:id/export/*splat', async (req, res) => {
try { try {
if (!isSafeId(req.params.id)) { if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id'); return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
@ -512,7 +512,8 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
} }
const project = getProject(db, req.params.id); const project = getProject(db, req.params.id);
const relPath = (req.params as any)[0]; const splatParam = (req.params as { splat?: string | string[] }).splat;
const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? '');
// PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE // PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE
// readProjectFile so a 100 MiB owner HTML is rejected after a // readProjectFile so a 100 MiB owner HTML is rejected after a

View file

@ -0,0 +1,144 @@
// Channel-root installation identity.
//
// `installationId` was historically stored in `<dataRoot>/app-config.json`,
// which lives at `<userData>/namespaces/<namespace>/data/app-config.json`
// in packaged builds. Two reinstall scenarios then silently rotated the
// id:
//
// 1. **Namespace churn.** If a packaged build bakes a different
// `namespace` token than the previous version, the daemon writes to
// a different `<namespace>/data/` subtree and the old installationId
// is invisible. The user shows up in PostHog as a brand-new person.
//
// 2. **Clean reinstall.** Even when the namespace is stable, anything
// that wipes `<userData>/namespaces/<ns>/data/` (a future installer
// that resets per-namespace data, a manual `rm -rf`) takes the id
// down with it.
//
// To preserve person continuity across both, we mirror the id into a
// stable file at the **channel root** — one level above the namespaces
// directory — and treat that file as authoritative on read. The legacy
// app-config.json field is still written so any code path that reads it
// directly (legacy / future fallbacks) keeps seeing the same value.
//
// Locations:
//
// packaged (mac): ~/Library/Application Support/Open Design Nightly/installation.json
// packaged (win): %APPDATA%/Open Design Nightly/installation.json
// packaged (linux): $XDG_CONFIG_HOME/Open Design Nightly/installation.json
// tools-dev / OSS: <dataDir>/installation.json (no namespace concept; fall back to dataDir)
//
// `OD_INSTALLATION_DIR` is the env override. Packaged sidecars.ts sets it
// to the channel root explicitly; everything else falls back to dataDir
// (where it sits next to app-config.json and behaves like the legacy
// path — fine for dev because dev doesn't have namespace churn).
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
/**
* Wire shape persisted at `<installationDir>/installation.json`.
* Kept intentionally narrow: only fields that need to survive a
* namespace-scoped data-dir wipe belong here.
*/
export interface InstallationFile {
installationId?: string;
// Future fields (privacy decision timestamp, telemetry flags) can join
// this list as soon as we have a use case for "they must outlive a
// namespace reset". Today, only installationId carries that contract.
}
export function resolveInstallationDir(dataDir: string): string {
const env = process.env.OD_INSTALLATION_DIR;
if (env && env.length > 0) return env;
return dataDir;
}
function installationFilePath(installationDir: string): string {
return join(installationDir, 'installation.json');
}
export async function readInstallationFile(
installationDir: string,
): Promise<InstallationFile> {
try {
const raw = await readFile(installationFilePath(installationDir), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
const obj = parsed as Record<string, unknown>;
const out: InstallationFile = {};
if (typeof obj.installationId === 'string' && obj.installationId.length > 0) {
out.installationId = obj.installationId;
}
return out;
} catch (err) {
const e = err as { code?: string; name?: string };
if (e.code === 'ENOENT') return {};
if (e.name === 'SyntaxError') return {};
// Anything else (permission denied, EIO) — treat as empty so the
// fallback path through app-config.json keeps the daemon alive.
return {};
}
}
// Serialize writes to the same installationDir so concurrent persists
// can't truncate each other. Mirrors the writeAppConfig lock strategy.
const writeLocks = new Map<string, Promise<unknown>>();
/**
* Patch shape for {@link writeInstallationFile}.
*
* Distinct from `Partial<InstallationFile>` because `exactOptionalPropertyTypes`
* blocks `{ installationId: undefined }` and we explicitly need a way to
* **clear** the id (Settings "Delete my data", or an explicit null write
* via `writeAppConfig`). The convention here:
*
* - field present with a non-empty string assign the new value
* - field present with null / empty string delete the field on disk
* - field absent leave the existing value alone
*/
export type InstallationFilePatch = {
installationId?: string | null;
};
export async function writeInstallationFile(
installationDir: string,
patch: InstallationFilePatch,
): Promise<InstallationFile> {
const prev = writeLocks.get(installationDir) ?? Promise.resolve();
const task = prev.catch(() => undefined).then(() => doWrite(installationDir, patch));
writeLocks.set(installationDir, task);
try {
return await task;
} finally {
if (writeLocks.get(installationDir) === task) writeLocks.delete(installationDir);
}
}
async function doWrite(
installationDir: string,
patch: InstallationFilePatch,
): Promise<InstallationFile> {
const existing = await readInstallationFile(installationDir);
const next: InstallationFile = { ...existing };
if (Object.prototype.hasOwnProperty.call(patch, 'installationId')) {
if (typeof patch.installationId === 'string' && patch.installationId.length > 0) {
next.installationId = patch.installationId;
} else {
delete next.installationId;
}
}
await mkdir(dirname(installationFilePath(installationDir)), { recursive: true });
// The file is small, the user only writes it on consent + delete-my-data
// flows. We deliberately don't use a temp-file + rename dance: a partial
// write here just means `readInstallationFile` falls back to app-config.json,
// which is the same fallback we use when the file simply doesn't exist yet.
await writeFile(
installationFilePath(installationDir),
JSON.stringify(next, null, 2) + '\n',
'utf8',
);
return next;
}

View file

@ -0,0 +1,85 @@
export type AmrAccountErrorCode = 'AMR_AUTH_REQUIRED' | 'AMR_INSUFFICIENT_BALANCE';
export interface AmrAccountFailure {
code: AmrAccountErrorCode;
message: string;
action: 'relogin' | 'recharge';
actionUrl?: string;
}
export const DEFAULT_AMR_RECHARGE_URL = 'https://open-design.ai/amr/wallet';
const AMR_AUTH_REQUIRED_MESSAGE =
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.';
const AMR_INSUFFICIENT_BALANCE_MESSAGE =
`AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`;
function normalizeFailureText(text: string): string {
return String(text || '').toLowerCase();
}
function containsInsufficientBalanceSignal(value: string): boolean {
if (
value.includes('insufficient_balance') ||
value.includes('insufficient balance') ||
value.includes('insufficient wallet balance') ||
value.includes('insufficient credits') ||
value.includes('insufficient credit') ||
value.includes('insufficient funds') ||
value.includes('not enough balance') ||
value.includes('not enough credits') ||
value.includes('balance is empty') ||
value.includes('balance too low') ||
value.includes('billing balance')
) {
return true;
}
return value.includes('quota') && /\b(wallet|balance|credit|billing|funds?)\b/.test(value);
}
export function classifyAmrAccountFailure(text: string): AmrAccountFailure | null {
const value = normalizeFailureText(text);
if (!value.trim()) return null;
if (containsInsufficientBalanceSignal(value)) {
return {
code: 'AMR_INSUFFICIENT_BALANCE',
message: AMR_INSUFFICIENT_BALANCE_MESSAGE,
action: 'recharge',
actionUrl: DEFAULT_AMR_RECHARGE_URL,
};
}
if (
value.includes('auth_required') ||
value.includes('authentication required') ||
value.includes('not authenticated') ||
value.includes('unauthenticated') ||
value.includes('not logged in') ||
value.includes('login missing') ||
value.includes('sign in again') ||
value.includes('sign-in required') ||
value.includes('signin required') ||
value.includes('token has expired') ||
value.includes('expired token') ||
value.includes('invalid session') ||
value.includes('session expired')
) {
return {
code: 'AMR_AUTH_REQUIRED',
message: AMR_AUTH_REQUIRED_MESSAGE,
action: 'relogin',
};
}
return null;
}
export function amrAccountFailureDetails(failure: AmrAccountFailure) {
return {
kind: 'amr_account',
action: failure.action,
...(failure.actionUrl ? { actionUrl: failure.actionUrl } : {}),
};
}

View file

@ -0,0 +1,21 @@
const AMR_PROFILE_ENV = 'OPEN_DESIGN_AMR_PROFILE';
const DEFAULT_PROFILE = 'prod';
const ALLOWED_PROFILES = new Set(['prod', 'test', 'local']);
export type AmrProfile = 'prod' | 'test' | 'local';
type EnvMap = NodeJS.ProcessEnv | Record<string, string | undefined>;
export function resolveAmrProfile(env: EnvMap = process.env): AmrProfile {
const raw = (env[AMR_PROFILE_ENV] || '').trim();
if (!raw) return DEFAULT_PROFILE;
if (ALLOWED_PROFILES.has(raw)) return raw as AmrProfile;
console.warn(
`[amr] invalid ${AMR_PROFILE_ENV}="${raw}"; falling back to ${DEFAULT_PROFILE}`,
);
return DEFAULT_PROFILE;
}
export function amrVelaProfileEnv(env: EnvMap = process.env): { VELA_PROFILE: AmrProfile } {
return { VELA_PROFILE: resolveAmrProfile(env) };
}

View file

@ -0,0 +1,279 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { createCommandInvocation } from '@open-design/platform';
import { resolveAgentLaunch } from '../runtimes/launch.js';
import { spawnEnvForAgent } from '../runtimes/env.js';
import { getAgentDef } from '../runtimes/registry.js';
import { resolveAmrProfile } from './vela-profile.js';
export { resolveAmrProfile } from './vela-profile.js';
export interface VelaUser {
id: string;
email: string;
name?: string;
image?: string | null;
plan?: string;
}
export interface VelaLoginStatus {
loggedIn: boolean;
loginInFlight: boolean;
profile: string;
user: VelaUser | null;
configPath: string;
}
interface VelaProfileShape {
controlKey?: string;
runtimeKey?: string;
apiUrl?: string;
linkUrl?: string;
user?: VelaUser | null;
}
interface VelaConfigFileShape {
profiles?: Record<string, VelaProfileShape>;
}
export function mergeVelaEnv(
env: NodeJS.ProcessEnv = process.env,
configuredEnv: Record<string, string> = {},
): NodeJS.ProcessEnv {
return {
...env,
...configuredEnv,
};
}
function configDir(): string {
return path.join(homedir(), '.amr');
}
export function amrConfigPath(): string {
return path.join(configDir(), 'config.json');
}
function readConfigFile(): VelaConfigFileShape | null {
const file = amrConfigPath();
if (!existsSync(file)) return null;
try {
const data = readFileSync(file, 'utf8');
const parsed = JSON.parse(data) as unknown;
if (!parsed || typeof parsed !== 'object') return null;
return parsed as VelaConfigFileShape;
} catch {
return null;
}
}
export function readVelaLoginStatus(
env: NodeJS.ProcessEnv = process.env,
configuredEnv: Record<string, string> = {},
): VelaLoginStatus {
const mergedEnv = mergeVelaEnv(env, configuredEnv);
const profile = resolveAmrProfile(mergedEnv);
const configPath = amrConfigPath();
const loginInFlight = isVelaLoginInFlight();
const runtimeKey = mergedEnv.VELA_RUNTIME_KEY?.trim() ?? '';
const linkUrl = mergedEnv.VELA_LINK_URL?.trim() ?? '';
if (runtimeKey && linkUrl) {
return { loggedIn: true, loginInFlight, profile, user: null, configPath };
}
const file = readConfigFile();
const stored = file?.profiles?.[profile];
const storedRuntimeKey = stored?.runtimeKey?.trim() ?? '';
if (!storedRuntimeKey) {
return { loggedIn: false, loginInFlight, profile, user: null, configPath };
}
const rawUser = stored?.user ?? null;
const user: VelaUser | null = rawUser
? {
id: typeof rawUser.id === 'string' ? rawUser.id : '',
email: typeof rawUser.email === 'string' ? rawUser.email : '',
...(typeof rawUser.name === 'string' ? { name: rawUser.name } : {}),
...(typeof rawUser.image === 'string' ? { image: rawUser.image } : {}),
...(typeof rawUser.plan === 'string' ? { plan: rawUser.plan } : {}),
}
: null;
return { loggedIn: true, loginInFlight, profile, user, configPath };
}
export function forgetVelaLogin(env: NodeJS.ProcessEnv = process.env): void {
const file = amrConfigPath();
if (!existsSync(file)) return;
const parsed = readConfigFile();
if (!parsed?.profiles) return;
const profile = resolveAmrProfile(env);
if (!Object.prototype.hasOwnProperty.call(parsed.profiles, profile)) return;
const keptProfileConfig = { ...(parsed.profiles[profile] ?? {}) };
delete keptProfileConfig.controlKey;
delete keptProfileConfig.runtimeKey;
delete keptProfileConfig.user;
const nextProfiles = { ...parsed.profiles };
nextProfiles[profile] = keptProfileConfig;
writeFileSync(
file,
JSON.stringify({ ...parsed, profiles: nextProfiles }, null, 2),
'utf8',
);
}
export interface SpawnedVelaLogin {
pid: number;
startedAt: string;
profile: string;
}
const activeLoginProcs = new Map<number, ChildProcess>();
const LOGIN_STARTUP_GRACE_MS = 250;
const LOGIN_CANCEL_KILL_GRACE_MS = 2000;
function isChildRunning(child: ChildProcess): boolean {
return child.exitCode === null && child.signalCode === null;
}
export function isVelaLoginInFlight(): boolean {
for (const [pid, child] of activeLoginProcs) {
if (isChildRunning(child)) return true;
activeLoginProcs.delete(pid);
}
return false;
}
export interface CancelVelaLoginResult {
canceled: boolean;
pids: number[];
}
export function cancelVelaLogin(): CancelVelaLoginResult {
const pids: number[] = [];
for (const [pid, child] of activeLoginProcs) {
if (!isChildRunning(child)) {
activeLoginProcs.delete(pid);
continue;
}
try {
child.kill('SIGTERM');
} catch {
activeLoginProcs.delete(pid);
continue;
}
pids.push(pid);
const killTimer = setTimeout(() => {
try {
if (isChildRunning(child)) child.kill('SIGKILL');
} catch {
activeLoginProcs.delete(pid);
}
}, LOGIN_CANCEL_KILL_GRACE_MS);
killTimer.unref?.();
}
return { canceled: pids.length > 0, pids };
}
export interface SpawnVelaLoginDeps {
configuredEnv?: Record<string, string>;
baseEnv?: NodeJS.ProcessEnv;
}
async function waitForImmediateLoginFailure(child: ChildProcess): Promise<void> {
let stderr = '';
let stdout = '';
child.stderr?.setEncoding('utf8');
child.stdout?.setEncoding('utf8');
child.stderr?.on('data', (chunk) => {
if (stderr.length < 4096) stderr += String(chunk);
});
child.stdout?.on('data', (chunk) => {
if (stdout.length < 4096) stdout += String(chunk);
});
const result = await new Promise<
| { kind: 'running' }
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
| { kind: 'error'; error: Error }
>((resolve) => {
let settled = false;
const finish = (
value:
| { kind: 'running' }
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
| { kind: 'error'; error: Error },
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const timer = setTimeout(
() => finish({ kind: 'running' }),
LOGIN_STARTUP_GRACE_MS,
);
child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
child.once('error', (error) => finish({ kind: 'error', error }));
});
if (result.kind === 'running') return;
if (result.kind === 'error') {
throw new Error(`vela login failed to start: ${result.error.message}`);
}
if (result.code === 0) return;
const detail = (stderr || stdout).trim();
throw new Error(
detail ||
`vela login exited before authentication completed (code ${result.code ?? 'null'}, signal ${result.signal ?? 'null'})`,
);
}
export async function spawnVelaLogin(
deps: SpawnVelaLoginDeps = {},
): Promise<SpawnedVelaLogin> {
if (isVelaLoginInFlight()) {
throw new Error('vela login already running');
}
const def = getAgentDef('amr');
if (!def) throw new Error('AMR runtime def not registered');
const baseEnv = deps.baseEnv ?? process.env;
const configuredEnv = deps.configuredEnv ?? {};
const launch = resolveAgentLaunch(def, configuredEnv);
const bin = launch.selectedPath;
if (!bin) {
throw new Error('vela binary not found; install vela or configure VELA_BIN');
}
const env = spawnEnvForAgent('amr', baseEnv, configuredEnv);
// Route through createCommandInvocation so an npm/Node-style `vela.cmd` or
// `vela.bat` shim on Windows gets wrapped under `cmd.exe /d /s /c …` with
// verbatim args, matching what `execAgentFile` / chat-run spawning do. A
// direct `spawn(bin, args)` on a `.cmd` shim quietly fails to find the
// shim's actual entry point. POSIX is unchanged (no wrapping needed).
const invocation = createCommandInvocation({ command: bin, args: ['login'], env });
const child = spawn(invocation.command, invocation.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env,
detached: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
if (typeof child.pid !== 'number') {
throw new Error('failed to spawn vela login');
}
activeLoginProcs.set(child.pid, child);
const cleanup = () => {
if (typeof child.pid === 'number') activeLoginProcs.delete(child.pid);
};
child.once('exit', cleanup);
child.once('error', cleanup);
await waitForImmediateLoginFailure(child);
// We don't surface URL/code in this API — vela CLI opens the browser itself
// (via OpenBrowser in apps/cli/internal/commands/login.go). Callers poll
// readVelaLoginStatus() to detect completion.
return {
pid: child.pid,
startedAt: new Date().toISOString(),
profile: resolveAmrProfile(env),
};
}

View file

@ -45,6 +45,54 @@ function stringifyContent(value: unknown): string {
} }
} }
function parseJsonObjectsFromContent(value: string): JsonObject[] {
const trimmed = value.trim();
if (!trimmed) return [];
const direct = safeParseJson(trimmed);
if (isRecord(direct)) return [direct];
const objects: JsonObject[] = [];
for (const line of trimmed.split(/\r?\n/u)) {
const parsedLine = safeParseJson(line.trim());
if (isRecord(parsedLine)) objects.push(parsedLine);
}
return objects;
}
function extractConnectorApiError(value: JsonObject): JsonObject | null {
if (isRecord(value.error)) {
if (typeof value.error.code === 'string') return value.error;
if (isRecord(value.error.data) && isRecord(value.error.data.error)) {
const wrappedError = value.error.data.error;
if (typeof wrappedError.code === 'string') return wrappedError;
}
}
return null;
}
function connectorToolSelectionErrorMessage(content: string): string | null {
if (!content.includes('CONNECTOR_TOOL_NOT_FOUND')) return null;
let error: JsonObject | null = null;
for (const parsed of parseJsonObjectsFromContent(content)) {
const parsedError = extractConnectorApiError(parsed);
if (parsedError?.code === 'CONNECTOR_TOOL_NOT_FOUND') {
error = parsedError;
break;
}
}
if (!error) return null;
const details = isRecord(error.details) ? error.details : {};
const connectorId = typeof details.connectorId === 'string' && details.connectorId
? details.connectorId
: undefined;
const toolName = typeof details.toolName === 'string' && details.toolName
? details.toolName
: 'the requested connector tool';
const target = connectorId
? `Connector tool ${toolName} is not allowed for connector ${connectorId}.`
: `Connector tool ${toolName} is not allowed.`;
return `${target} Re-list the connector catalog and choose one of the currently allowed read-only tools.`;
}
function extractErrorMessage(value: unknown, fallback: string): string { function extractErrorMessage(value: unknown, fallback: string): string {
if (typeof value === 'string') { if (typeof value === 'string') {
const parsed = safeParseJson(value); const parsed = safeParseJson(value);
@ -71,6 +119,16 @@ function extractErrorMessage(value: unknown, fallback: string): string {
return fallback; return fallback;
} }
function isRecoverableCodexReconnect(message: string): boolean {
return (
message.startsWith('Reconnecting...') &&
(
message.includes('timeout waiting for child process to exit') ||
message.includes('stream disconnected before completion')
)
);
}
function formatOpenCodeUsage(tokens: unknown): Usage | null { function formatOpenCodeUsage(tokens: unknown): Usage | null {
if (!isRecord(tokens)) return null; if (!isRecord(tokens)) return null;
const usage: Usage = {}; const usage: Usage = {};
@ -272,11 +330,7 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars
if (obj.type === 'error') { if (obj.type === 'error') {
const message = extractErrorMessage(obj.message ?? obj.error, 'Codex error'); const message = extractErrorMessage(obj.message ?? obj.error, 'Codex error');
// Reconnecting events are recoverable — treat as status warning, not fatal // Reconnecting events are recoverable — treat as status warning, not fatal
if ( if (isRecoverableCodexReconnect(message)) {
typeof message === 'string' &&
message.includes('Reconnecting...') &&
message.includes('timeout waiting for child process to exit')
) {
onEvent({ type: 'status', label: message }); onEvent({ type: 'status', label: message });
return true; return true;
} }
@ -346,12 +400,18 @@ if (obj.type === 'error') {
}, },
}); });
} }
const content = stringifyContent(item.aggregated_output ?? '');
onEvent({ onEvent({
type: 'tool_result', type: 'tool_result',
toolUseId: item.id, toolUseId: item.id,
content: stringifyContent(item.aggregated_output ?? ''), content,
isError: typeof item.exit_code === 'number' ? item.exit_code !== 0 : item.status === 'failed', isError: typeof item.exit_code === 'number' ? item.exit_code !== 0 : item.status === 'failed',
}); });
const connectorToolError = connectorToolSelectionErrorMessage(content);
if (connectorToolError && !state.codexErrorEmitted) {
state.codexErrorEmitted = true;
onEvent({ type: 'error', message: connectorToolError });
}
return true; return true;
} }
} }

View file

@ -14,9 +14,12 @@ import { readAppConfig } from './app-config.js';
import type { AppVersionInfo } from './app-version.js'; import type { AppVersionInfo } from './app-version.js';
import { listMessages } from './db.js'; import { listMessages } from './db.js';
import { import {
readTelemetrySinkConfig,
reportRunCompleted, reportRunCompleted,
reportRunFeedback,
type ArtifactSummary, type ArtifactSummary,
type EventsSummary, type EventsSummary,
type FeedbackReportContext,
type MessageSummary, type MessageSummary,
type ReportContext, type ReportContext,
type RuntimeInfo, type RuntimeInfo,
@ -357,3 +360,71 @@ export async function reportRunCompletedFromDaemon(
console.warn('[langfuse-bridge] report failed:', String(err)); console.warn('[langfuse-bridge] report failed:', String(err));
} }
} }
export interface ReportRunFeedbackFromDaemonOpts {
dataDir: string;
runId: string;
rating: 'positive' | 'negative';
reasonCodes: string[];
hasCustomReason: boolean;
/** Raw "other" free text. Empty when no custom reason. */
customReason: string;
/** Extra context for Langfuse score metadata (projectId / conversationId / assistantMessageId). */
scoreMetadata?: Record<string, unknown>;
fetchImpl?: typeof fetch;
}
/**
* Result for the POST /api/runs/:id/feedback handler. Telemetry is
* best-effort and the network call runs after the response is sent, but
* the handler still tells the caller whether the report was at least
* enqueued useful for QA and e2e.
*/
export type FeedbackReportOutcome =
| { status: 'accepted' }
| { status: 'skipped_consent' }
| { status: 'skipped_no_sink' };
export async function reportRunFeedbackFromDaemon(
opts: ReportRunFeedbackFromDaemonOpts,
): Promise<FeedbackReportOutcome> {
let cfg;
try {
cfg = await readAppConfig(opts.dataDir);
} catch (err) {
console.warn('[langfuse-bridge] feedback config read failed:', String(err));
return { status: 'skipped_no_sink' };
}
const prefs = cfg.telemetry ?? {};
if (prefs.metrics !== true || prefs.content !== true) {
return { status: 'skipped_consent' };
}
// Pre-resolve the sink before claiming `accepted`. Avoids advertising a
// successful enqueue to callers when there's no Langfuse endpoint
// configured to ship the score to.
const sink = readTelemetrySinkConfig();
if (!sink) {
return { status: 'skipped_no_sink' };
}
const ctx: FeedbackReportContext = {
runId: opts.runId,
installationId: cfg.installationId ?? null,
prefs,
rating: opts.rating,
reasonCodes: opts.reasonCodes,
hasCustomReason: opts.hasCustomReason,
customReason: opts.customReason,
...(opts.scoreMetadata ? { metadata: opts.scoreMetadata } : {}),
};
// Fire-and-forget the actual network send so the route can respond
// immediately. The handler's response already encodes the consent +
// sink-presence outcome above; failures inside the send are operational
// telemetry, not a client-facing signal.
void reportRunFeedback(
ctx,
opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {},
).catch((err) => {
console.warn('[langfuse-bridge] feedback report failed:', String(err));
});
return { status: 'accepted' };
}

View file

@ -151,6 +151,29 @@ export interface ReportRunOpts {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
} }
/**
* Payload sent to Langfuse when a user thumbs-up/down's an assistant turn.
*
* The `runId` doubles as the Langfuse trace id (same convention used by
* buildTracePayload), so the score lands on the existing trace if the run
* was previously reported. If the run wasn't reported (e.g. content
* consent was off at run completion, then turned on before the user
* scored), Langfuse will accept the score anyway and the trace will
* materialize when/if the daemon backfills it.
*/
export interface FeedbackReportContext {
runId: string;
installationId: string | null;
prefs: TelemetryPrefs;
rating: 'positive' | 'negative';
reasonCodes: string[];
/** Raw "other" free text the user typed. Trimmed; empty string when absent. */
customReason: string;
hasCustomReason: boolean;
/** Optional context bag that ends up in Langfuse score metadata. */
metadata?: Record<string, unknown>;
}
export function readLangfuseConfig( export function readLangfuseConfig(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): LangfuseConfig | null { ): LangfuseConfig | null {
@ -658,3 +681,105 @@ export async function reportRunCompleted(
} }
await postLangfuseBatch(config, batch, fetchImpl); await postLangfuseBatch(config, batch, fetchImpl);
} }
// Build a Langfuse `score-create` batch for a user-supplied turn rating.
//
// Langfuse scores let evals filter traces by user feedback. We emit one
// NUMERIC score (`user_rating`, +1 / -1) plus optional CATEGORICAL scores
// for each reason code, so the Langfuse UI's score filters work out of
// the box. Raw custom-reason text rides in the score metadata when the
// user opted into telemetry.content; the consent gate lives in
// reportRunFeedback below, so this builder stays content-agnostic.
//
// Limitation: stable score ids (`${traceId}-rating`, `${traceId}-reason-${code}`)
// mean re-submission overwrites cleanly, but reason codes the user removes
// in a follow-up submission do not get a tombstone. A future change can
// thread `removedReasonCodes` through and emit overwriting "cleared"
// scores for them; not done here to keep this PR scoped to the bridge.
export function buildFeedbackPayload(ctx: FeedbackReportContext): unknown[] {
const traceId = ctx.runId;
const nowIso = new Date().toISOString();
const batch: unknown[] = [];
const ratingMetadata: Record<string, unknown> = {
reasonCodes: ctx.reasonCodes,
reasonCount: ctx.reasonCodes.length,
hasCustomReason: ctx.hasCustomReason,
// Raw text — gated upstream by telemetry.content consent.
customReason: ctx.customReason || undefined,
installationId: ctx.installationId ?? undefined,
...(ctx.metadata ?? {}),
};
batch.push({
id: randomUUID(),
type: 'score-create',
timestamp: nowIso,
body: {
id: `${traceId}-rating`,
traceId,
name: 'user_rating',
value: ctx.rating === 'positive' ? 1 : -1,
dataType: 'NUMERIC',
comment: ctx.rating,
metadata: ratingMetadata,
},
});
for (const code of ctx.reasonCodes) {
batch.push({
id: randomUUID(),
type: 'score-create',
timestamp: nowIso,
body: {
// Stable per (run, code) so re-submission overwrites cleanly.
id: `${traceId}-reason-${code}`,
traceId,
name: 'user_rating_reason',
value: code,
dataType: 'CATEGORICAL',
// Group the reason under the rating it was submitted with so a
// "matched_request" tag on a thumbs-down run is still visibly
// negative in the Langfuse UI.
comment: ctx.rating,
},
});
}
return batch;
}
export async function reportRunFeedback(
ctx: FeedbackReportContext,
opts: ReportRunOpts = {},
): Promise<void> {
if (ctx.prefs.metrics !== true) return;
if (ctx.prefs.content !== true) return;
const config = resolveReportConfig(opts);
if (!config) return;
let batch: unknown[];
try {
batch = buildFeedbackPayload(ctx);
} catch (error) {
console.warn(`[langfuse-trace] Feedback payload build error: ${String(error)}`);
return;
}
const serialized = JSON.stringify({ batch });
const serializedBytes = Buffer.byteLength(serialized, 'utf8');
if (serializedBytes > HARD_BATCH_MAX_BYTES) {
console.warn(
`[langfuse-trace] Feedback batch too large (${serializedBytes}B > ${HARD_BATCH_MAX_BYTES}B), dropping feedback for ${ctx.runId}`,
);
return;
}
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
if (config.kind === 'relay') {
await postRelayBatch(config, serialized, fetchImpl);
return;
}
await postLangfuseBatch(config, batch, fetchImpl);
}

View file

@ -28,6 +28,13 @@ export interface BuildMcpInstallPayloadInputs {
* caller wants propagated into the snippet. The caller decides * caller wants propagated into the snippet. The caller decides
* what's worth propagating; this builder just merges. */ * what's worth propagating; this builder just merges. */
sidecarEnv: Record<string, string>; sidecarEnv: Record<string, string>;
/** Browser-facing Open Design studio base URL (e.g.
* `http://127.0.0.1:65321`). Used by MCP clients to build deep
* links to `/projects/.../conversations/.../files/...` so the
* outer agent can suggest a URL that shows both the file preview
* and the chat history for the run. Null when the daemon was
* launched without a known web port (CLI-only / headless). */
webBaseUrl?: string | null;
} }
export interface McpInstallPayload { export interface McpInstallPayload {
@ -35,6 +42,10 @@ export interface McpInstallPayload {
args: string[]; args: string[];
env: Record<string, string>; env: Record<string, string>;
daemonUrl: string; daemonUrl: string;
/** Browser-facing studio base URL the daemon is paired with, when
* known. MCP clients use this plus run/project context to build a
* studio deep link the outer agent can hand back to the user. */
webBaseUrl: string | null;
platform: NodeJS.Platform; platform: NodeJS.Platform;
cliExists: boolean; cliExists: boolean;
nodeExists: boolean; nodeExists: boolean;
@ -85,6 +96,10 @@ export function buildMcpInstallPayload(
args, args,
env, env,
daemonUrl: `http://127.0.0.1:${inputs.port}`, daemonUrl: `http://127.0.0.1:${inputs.port}`,
webBaseUrl:
typeof inputs.webBaseUrl === 'string' && inputs.webBaseUrl.length > 0
? inputs.webBaseUrl
: null,
// Surface platform so the install panel can localize path hints // Surface platform so the install panel can localize path hints
// (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts // (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts
// (Cmd vs Ctrl). // (Cmd vs Ctrl).

View file

@ -1,7 +1,8 @@
import type { Express } from 'express'; import type { Express } from 'express';
import fs from 'node:fs'; import fs from 'node:fs';
import { SIDECAR_ENV } from '@open-design/sidecar-proto'; import { SIDECAR_ENV } from '@open-design/sidecar-proto';
import { buildMcpInstallPayload } from './mcp-install-info.js'; import { buildMcpInstallPayload, type McpInstallPayload } from './mcp-install-info.js';
import { installCodexMcp, probeCodexInstall, uninstallCodexMcp } from './codex-cli.js';
import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js'; import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js';
import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js'; import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js';
import { clearToken, getToken, isTokenExpired, readAllTokens, setToken } from './mcp-tokens.js'; import { clearToken, getToken, isTokenExpired, readAllTokens, setToken } from './mcp-tokens.js';
@ -24,20 +25,15 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
const INSTALL_INFO_TTL_MS = 5000; const INSTALL_INFO_TTL_MS = 5000;
let installInfoCache: { t: number; payload: object } | null = null; let installInfoCache: { t: number; payload: object } | null = null;
app.get('/api/mcp/install-info', (req, res) => { // Resolve the install snippet for the current daemon. Shared by the
if (!isLocalSameOrigin(req, getResolvedPort())) { // public GET /api/mcp/install-info endpoint (renders TOML/JSON for
return res.status(403).json({ error: 'cross-origin request rejected' }); // the user to copy) and POST /api/mcp/install/codex (which feeds the
} // exact same fields into `codex mcp add` so the one-click install
const now = Date.now(); // configures Codex with byte-for-byte the same command as the copy
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) { // snippet would). Keeping this in one place is the whole point of
return res.json(installInfoCache.payload); // the factoring — divergence here would mean Codex behaves
} // differently depending on which install path the user took.
// process.execPath is the absolute path to the Node-compatible function computeInstallPayload(): McpInstallPayload {
// runtime that is running the daemon RIGHT NOW. In packaged builds
// this may be Electron running with ELECTRON_RUN_AS_NODE=1 rather
// than a separate bundled Node binary; the helper surfaces that env
// requirement with the command so IDE-spawned MCP clients can
// reproduce the same mode from a minimal OS launcher environment.
const cliPath = OD_BIN; const cliPath = OD_BIN;
// The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff // The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff
// bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env. // bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env.
@ -53,9 +49,25 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
if (isSidecarMode) { if (isSidecarMode) {
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
} }
const payload = buildMcpInstallPayload({ // tools-dev / packaged launchers export OD_WEB_PORT so the daemon
// knows where the browser-facing Open Design studio is running.
// CLI-only / headless launches set neither and webBaseUrl falls
// through as null — MCP clients then just omit the studio deep
// link from their responses.
const webPortRaw = process.env.OD_WEB_PORT;
const webPortNum = webPortRaw ? Number(webPortRaw) : Number.NaN;
const webBaseUrl = Number.isFinite(webPortNum) && webPortNum > 0
? `http://127.0.0.1:${webPortNum}`
: null;
return buildMcpInstallPayload({
cliPath, cliPath,
cliExists: fs.existsSync(cliPath), cliExists: fs.existsSync(cliPath),
// process.execPath is the absolute path to the Node-compatible
// runtime running the daemon RIGHT NOW. In packaged builds this
// may be Electron with ELECTRON_RUN_AS_NODE=1 rather than a
// separate bundled Node binary; the helper surfaces that env
// requirement on the command so IDE-spawned MCP clients can
// reproduce the same mode from a minimal OS launcher env.
execPath: process.execPath, execPath: process.execPath,
nodeExists: fs.existsSync(process.execPath), nodeExists: fs.existsSync(process.execPath),
port: getResolvedPort(), port: getResolvedPort(),
@ -64,11 +76,74 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
electronAsNode: process.env.ELECTRON_RUN_AS_NODE === '1', electronAsNode: process.env.ELECTRON_RUN_AS_NODE === '1',
isSidecarMode, isSidecarMode,
sidecarEnv, sidecarEnv,
webBaseUrl,
}); });
}
app.get('/api/mcp/install-info', (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const now = Date.now();
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
return res.json(installInfoCache.payload);
}
const payload = computeInstallPayload();
installInfoCache = { t: now, payload }; installInfoCache = { t: now, payload };
res.json(payload); res.json(payload);
}); });
// Codex one-click install. Codex CLI exposes `codex mcp add/remove/get`,
// so we shell out to it rather than rewriting ~/.codex/config.toml
// ourselves — that way we inherit Codex's merge / validation rules
// and only need to track its argv. See apps/daemon/src/codex-cli.ts.
const CODEX_MCP_NAME = 'open-design';
app.get('/api/mcp/install/codex/status', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const status = await probeCodexInstall(CODEX_MCP_NAME);
res.json(status);
} catch (err) {
sendApiError(res, 500, 'CODEX_PROBE_FAILED', err instanceof Error ? err.message : String(err));
}
});
app.post('/api/mcp/install/codex', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const payload = computeInstallPayload();
if (!payload.cliExists || !payload.nodeExists) {
return sendApiError(res, 500, 'INSTALL_INFO_INCOMPLETE', payload.buildHint ?? 'install payload not ready');
}
try {
await installCodexMcp({
name: CODEX_MCP_NAME,
command: payload.command,
args: payload.args,
env: payload.env,
});
res.json({ ok: true });
} catch (err) {
sendApiError(res, 500, 'CODEX_INSTALL_FAILED', err instanceof Error ? err.message : String(err));
}
});
app.delete('/api/mcp/install/codex', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
await uninstallCodexMcp(CODEX_MCP_NAME);
res.json({ ok: true });
} catch (err) {
sendApiError(res, 500, 'CODEX_UNINSTALL_FAILED', err instanceof Error ? err.message : String(err));
}
});
// External MCP server configuration. Open Design connects to these as a // External MCP server configuration. Open Design connects to these as a
// CLIENT and surfaces their tools to the underlying agent at spawn time. // CLIENT and surfaces their tools to the underlying agent at spawn time.
// GET returns user-saved entries plus the built-in template list so the UI // GET returns user-saved entries plus the built-in template list so the UI

Some files were not shown because too many files have changed in this diff Show more