From fa58883d7854cfebe283efe24d9ce36882d77891 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 18:38:51 +0800 Subject: [PATCH 01/53] Keep Vercel deployments resumable until provider settlement Vercel deployment acceptance is not terminal success. Declare non-terminal deployments as durable async operations, poll the exact deployment ID through the existing Runtime, and settle only on authoritative terminal provider states. The accompanying Spec Kit artifacts preserve the decision and regression contract. Constraint: Reuse the existing Runtime scheduler and settlement path without generic Runtime changes Rejected: Treat accepted or BUILDING deployments as success | loses the authoritative provider result Rejected: Poll deployment lists | cannot prove which deployment owns the operation Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not reclassify Vercel acceptance as terminal success or replay launch writes from poll continuations Tested: 102 scoped Vercel and Runtime tests; 172 deploy and Tool contract regression tests; scoped Ruff critical rules Not-tested: Live Vercel deployment and long-duration retry termination policy --- .specify/init-options.json | 11 + .specify/memory/constitution.md | 72 ++ .specify/scripts/bash/check-prerequisites.sh | 190 ++++ .specify/scripts/bash/common.sh | 329 +++++++ .specify/scripts/bash/create-new-feature.sh | 335 +++++++ .specify/scripts/bash/setup-plan.sh | 72 ++ .specify/scripts/bash/update-agent-context.sh | 837 ++++++++++++++++++ .specify/templates/agent-file-template.md | 28 + .specify/templates/checklist-template.md | 40 + .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 104 +++ .specify/templates/spec-template.md | 128 +++ .specify/templates/tasks-template.md | 251 ++++++ backend/app/services/agent_tools.py | 290 ++++-- .../test_agent_tools_typed_vercel_deploy.py | 131 ++- .../checklists/requirements.md | 34 + .../contracts/vercel-async-operation.md | 48 + specs/001-fix-vercel-async-wait/data-model.md | 39 + specs/001-fix-vercel-async-wait/plan.md | 81 ++ specs/001-fix-vercel-async-wait/quickstart.md | 39 + specs/001-fix-vercel-async-wait/research.md | 54 ++ specs/001-fix-vercel-async-wait/spec.md | 130 +++ specs/001-fix-vercel-async-wait/tasks.md | 80 ++ 23 files changed, 3304 insertions(+), 69 deletions(-) create mode 100644 .specify/init-options.json create mode 100644 .specify/memory/constitution.md create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/update-agent-context.sh create mode 100644 .specify/templates/agent-file-template.md create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/templates/plan-template.md create mode 100644 .specify/templates/spec-template.md create mode 100644 .specify/templates/tasks-template.md create mode 100644 specs/001-fix-vercel-async-wait/checklists/requirements.md create mode 100644 specs/001-fix-vercel-async-wait/contracts/vercel-async-operation.md create mode 100644 specs/001-fix-vercel-async-wait/data-model.md create mode 100644 specs/001-fix-vercel-async-wait/plan.md create mode 100644 specs/001-fix-vercel-async-wait/quickstart.md create mode 100644 specs/001-fix-vercel-async-wait/research.md create mode 100644 specs/001-fix-vercel-async-wait/spec.md create mode 100644 specs/001-fix-vercel-async-wait/tasks.md diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 000000000..f69b21e6f --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,11 @@ +{ + "ai": "codex", + "ai_commands_dir": null, + "ai_skills": true, + "branch_numbering": "sequential", + "here": true, + "offline": true, + "preset": null, + "script": "sh", + "speckit_version": "0.4.1" +} \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md new file mode 100644 index 000000000..3e163d6ee --- /dev/null +++ b/.specify/memory/constitution.md @@ -0,0 +1,72 @@ + +# Clawith Constitution + +## Core Principles + +### I. Evidence Before Claims +Current behavior MUST be established from source code, migrations, tests, or runtime evidence before +changes are designed. Provider facts, Runtime facts, Model output, and hypotheses MUST remain +separate. Point-in-time facts such as branches, versions, ports, commits, and deployment state MUST +be rechecked before they are reported. + +### II. Minimal Scoped Changes +Every implementation MUST stay inside the user-approved scope and use the smallest reversible diff +that fixes the demonstrated behavior. Existing utilities and contracts MUST be reused before new +abstractions are introduced. Adjacent refactors, new dependencies, and speculative hardening are +forbidden unless explicitly approved. + +### III. Contract and State Ownership +Each fact MUST have one authoritative owner. Provider-specific adapters own mapping external business +states into typed outcomes; Runtime owns Tool receipts, scheduling, waiting, settlement, and resume; +the Model owns intent and user-facing content. Consumers MUST use the structured contract rather than +re-deriving state from summaries or prose. + +### IV. Tests Prove Behavior +Bug fixes MUST include regression coverage for the failing path and its terminal outcomes. Tests MUST +prove both the desired result and prohibited side effects, such as duplicate external writes. Scoped +tests and relevant static checks MUST pass before completion is claimed; live verification MUST be +reported separately from local automated evidence. + +### V. Preserve Existing Work +Unrelated dirty-worktree changes belong to the user and MUST NOT be reverted, overwritten, or folded +into the feature. Files ignored by Git MUST be verified through direct filesystem inspection. Agents +MUST avoid destructive commands and MUST report unavoidable ownership conflicts before proceeding. + +## Project Constraints + +- Backend Runtime work uses the existing Python, FastAPI, SQLAlchemy, LangGraph, and pytest stack. +- No dependency may be added without explicit user approval. +- Documentation may describe historical intent, but implementation claims MUST be checked against + current source. +- Public Tool behavior and internal Runtime behavior MUST not be broadened merely to simplify one fix. +- External writes MUST remain exactly-once where the existing Tool policy requires it. + +## Development Workflow + +1. Define the observed failure, authoritative fact, consumer, and approved boundary. +2. Write a testable specification and identify prohibited changes. +3. Add or update scoped regression tests before the implementation when practical. +4. Implement the smallest contract-preserving change. +5. Run scoped pytest and Ruff checks, then inspect the final diff for unrelated changes. +6. Report changed files, verification evidence, and remaining risks without overstating live status. + +## Governance + +This constitution governs Spec Kit artifacts for Clawith and is subordinate only to explicit user +instructions and the repository `AGENTS.md`. Amendments require a documented rationale, semantic +version update, date update, and consistency review of dependent Spec Kit templates. Every feature +plan MUST evaluate these principles before design and again before implementation. Any exception MUST +be explicit in the plan's Complexity Tracking section and approved before code changes begin. + +**Version**: 1.0.0 | **Ratified**: 2026-08-05 | **Last Amended**: 2026-08-05 diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 000000000..024aba0eb --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths and validate branch +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# If paths-only mode, output paths and exit (support JSON + paths-only combined) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 000000000..6dd04a0e4 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory +get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found + if git rev-parse --show-toplevel >/dev/null 2>&1; then + git rev-parse --show-toplevel + return + fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current branch, with fallback for non-git repositories +get_current_branch() { + # First check if SPECIFY_FEATURE environment variable is set + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD + return + fi + + # For non-git repos, try to find the latest feature directory + local specs_dir="$repo_root/specs" + + if [[ -d "$specs_dir" ]]; then + local latest_feature="" + local highest=0 + local latest_timestamp="" + + for dir in "$specs_dir"/*; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi + fi + fi + fi + done + + if [[ -n "$latest_feature" ]]; then + echo "$latest_feature" + return + fi + fi + + echo "main" # Final fallback +} + +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) +has_git() { + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} + +get_feature_dir() { echo "$1/specs/$2"; } + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) +find_feature_dir_by_prefix() { + local repo_root="$1" + local branch_name="$2" + local specs_dir="$repo_root/specs" + + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match + echo "$specs_dir/$branch_name" + return + fi + + # Search for directories in specs/ that start with this prefix + local matches=() + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"$prefix"-*; do + if [[ -d "$dir" ]]; then + matches+=("$(basename "$dir")") + fi + done + fi + + # Handle results + if [[ ${#matches[@]} -eq 0 ]]; then + # No match found - return the branch name path (will fail later with clear error) + echo "$specs_dir/$branch_name" + elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! + echo "$specs_dir/${matches[0]}" + else + # Multiple matches - this shouldn't happen with proper naming convention + echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 + echo "Please ensure only one spec directory exists per prefix." >&2 + return 1 + fi +} + +get_feature_paths() { + local repo_root=$(get_repo_root) + local current_branch=$(get_current_branch) + local has_git_repo="false" + + if has_git; then + has_git_repo="true" + fi + + # Use prefix-based lookup to support multiple branches per spec + local feature_dir + if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 000000000..0c675d4de --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Only match sequential prefixes (###-*), skip timestamp dirs + if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$dirname" | grep -o '^[0-9]\{3\}') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + local highest=0 + + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$branches" + fi + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local specs_dir="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + + # Get highest number from ALL branches (not just matching short name) + local highest_branch=$(get_highest_from_branches) + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root using common.sh functions which prioritize .specify over git +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + # Calculate how much we need to trim from suffix + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$HAS_GIT" = true ]; then + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + fi + exit 1 + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." + exit 1 + fi + fi +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" +fi + +# Inform the user how to persist the feature variable in their own shell +printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 000000000..961d4bad6 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Check if we're on a proper feature branch (only for git repos) +check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if it exists +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true +if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + echo "Copied plan template to $IMPL_PLAN" +else + echo "Warning: Plan template not found" + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" + echo "HAS_GIT: $HAS_GIT" +fi diff --git a/.specify/scripts/bash/update-agent-context.sh b/.specify/scripts/bash/update-agent-context.sh new file mode 100755 index 000000000..02afd1493 --- /dev/null +++ b/.specify/scripts/bash/update-agent-context.sh @@ -0,0 +1,837 @@ +#!/usr/bin/env bash + +# Update agent context files with information from plan.md +# +# This script maintains AI agent context files by parsing feature specifications +# and updating agent-specific configuration files with project information. +# +# MAIN FUNCTIONS: +# 1. Environment Validation +# - Verifies git repository structure and branch information +# - Checks for required plan.md files and templates +# - Validates file permissions and accessibility +# +# 2. Plan Data Extraction +# - Parses plan.md files to extract project metadata +# - Identifies language/version, frameworks, databases, and project types +# - Handles missing or incomplete specification data gracefully +# +# 3. Agent File Management +# - Creates new agent context files from templates when needed +# - Updates existing agent files with new project information +# - Preserves manual additions and custom configurations +# - Supports multiple AI agent formats and directory structures +# +# 4. Content Generation +# - Generates language-specific build/test commands +# - Creates appropriate project directory structures +# - Updates technology stacks and recent changes sections +# - Maintains consistent formatting and timestamps +# +# 5. Multi-Agent Support +# - Handles agent-specific file paths and naming conventions +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic +# - Can update single agents or all existing agent files +# - Creates default Claude file if no agent files exist +# +# Usage: ./update-agent-context.sh [agent_type] +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic +# Leave empty to update all existing agent files + +set -e + +# Enable strict error handling +set -u +set -o pipefail + +#============================================================================== +# Configuration and Global Variables +#============================================================================== + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code +AGENT_TYPE="${1:-}" + +# Agent-specific file paths +CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" +GEMINI_FILE="$REPO_ROOT/GEMINI.md" +COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md" +CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" +QWEN_FILE="$REPO_ROOT/QWEN.md" +AGENTS_FILE="$REPO_ROOT/AGENTS.md" +WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" +JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md" +KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" +AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" +ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" +CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" +QODER_FILE="$REPO_ROOT/QODER.md" +# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid +# updating the same file multiple times. +AMP_FILE="$AGENTS_FILE" +SHAI_FILE="$REPO_ROOT/SHAI.md" +TABNINE_FILE="$REPO_ROOT/TABNINE.md" +KIRO_FILE="$AGENTS_FILE" +AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" +BOB_FILE="$AGENTS_FILE" +VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" +KIMI_FILE="$REPO_ROOT/KIMI.md" +TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md" +IFLOW_FILE="$REPO_ROOT/IFLOW.md" + +# Template file +TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" + +# Global variables for parsed plan data +NEW_LANG="" +NEW_FRAMEWORK="" +NEW_DB="" +NEW_PROJECT_TYPE="" + +#============================================================================== +# Utility Functions +#============================================================================== + +log_info() { + echo "INFO: $1" +} + +log_success() { + echo "✓ $1" +} + +log_error() { + echo "ERROR: $1" >&2 +} + +log_warning() { + echo "WARNING: $1" >&2 +} + +# Cleanup function for temporary files +cleanup() { + local exit_code=$? + # Disarm traps to prevent re-entrant loop + trap - EXIT INT TERM + rm -f /tmp/agent_update_*_$$ + rm -f /tmp/manual_additions_$$ + exit $exit_code +} + +# Set up cleanup trap +trap cleanup EXIT INT TERM + +#============================================================================== +# Validation Functions +#============================================================================== + +validate_environment() { + # Check if we have a current branch/feature (git or non-git) + if [[ -z "$CURRENT_BRANCH" ]]; then + log_error "Unable to determine current feature" + if [[ "$HAS_GIT" == "true" ]]; then + log_info "Make sure you're on a feature branch" + else + log_info "Set SPECIFY_FEATURE environment variable or create a feature first" + fi + exit 1 + fi + + # Check if plan.md exists + if [[ ! -f "$NEW_PLAN" ]]; then + log_error "No plan.md found at $NEW_PLAN" + log_info "Make sure you're working on a feature with a corresponding spec directory" + if [[ "$HAS_GIT" != "true" ]]; then + log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" + fi + exit 1 + fi + + # Check if template exists (needed for new files) + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_warning "Template file not found at $TEMPLATE_FILE" + log_warning "Creating new agent files will fail" + fi +} + +#============================================================================== +# Plan Parsing Functions +#============================================================================== + +extract_plan_field() { + local field_pattern="$1" + local plan_file="$2" + + grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ + head -1 | \ + sed "s|^\*\*${field_pattern}\*\*: ||" | \ + sed 's/^[ \t]*//;s/[ \t]*$//' | \ + grep -v "NEEDS CLARIFICATION" | \ + grep -v "^N/A$" || echo "" +} + +parse_plan_data() { + local plan_file="$1" + + if [[ ! -f "$plan_file" ]]; then + log_error "Plan file not found: $plan_file" + return 1 + fi + + if [[ ! -r "$plan_file" ]]; then + log_error "Plan file is not readable: $plan_file" + return 1 + fi + + log_info "Parsing plan data from $plan_file" + + NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") + NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") + NEW_DB=$(extract_plan_field "Storage" "$plan_file") + NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") + + # Log what we found + if [[ -n "$NEW_LANG" ]]; then + log_info "Found language: $NEW_LANG" + else + log_warning "No language information found in plan" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + log_info "Found framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + log_info "Found database: $NEW_DB" + fi + + if [[ -n "$NEW_PROJECT_TYPE" ]]; then + log_info "Found project type: $NEW_PROJECT_TYPE" + fi +} + +format_technology_stack() { + local lang="$1" + local framework="$2" + local parts=() + + # Add non-empty parts + [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") + [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") + + # Join with proper formatting + if [[ ${#parts[@]} -eq 0 ]]; then + echo "" + elif [[ ${#parts[@]} -eq 1 ]]; then + echo "${parts[0]}" + else + # Join multiple parts with " + " + local result="${parts[0]}" + for ((i=1; i<${#parts[@]}; i++)); do + result="$result + ${parts[i]}" + done + echo "$result" + fi +} + +#============================================================================== +# Template and Content Generation Functions +#============================================================================== + +get_project_structure() { + local project_type="$1" + + if [[ "$project_type" == *"web"* ]]; then + echo "backend/\\nfrontend/\\ntests/" + else + echo "src/\\ntests/" + fi +} + +get_commands_for_language() { + local lang="$1" + + case "$lang" in + *"Python"*) + echo "cd src && pytest && ruff check ." + ;; + *"Rust"*) + echo "cargo test && cargo clippy" + ;; + *"JavaScript"*|*"TypeScript"*) + echo "npm test \\&\\& npm run lint" + ;; + *) + echo "# Add commands for $lang" + ;; + esac +} + +get_language_conventions() { + local lang="$1" + echo "$lang: Follow standard conventions" +} + +create_new_agent_file() { + local target_file="$1" + local temp_file="$2" + local project_name="$3" + local current_date="$4" + + if [[ ! -f "$TEMPLATE_FILE" ]]; then + log_error "Template not found at $TEMPLATE_FILE" + return 1 + fi + + if [[ ! -r "$TEMPLATE_FILE" ]]; then + log_error "Template file is not readable: $TEMPLATE_FILE" + return 1 + fi + + log_info "Creating new agent context file from template..." + + if ! cp "$TEMPLATE_FILE" "$temp_file"; then + log_error "Failed to copy template file" + return 1 + fi + + # Replace template placeholders + local project_structure + project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + + local commands + commands=$(get_commands_for_language "$NEW_LANG") + + local language_conventions + language_conventions=$(get_language_conventions "$NEW_LANG") + + # Perform substitutions with error checking using safer approach + # Escape special characters for sed by using a different delimiter or escaping + local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') + local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') + + # Build technology stack and recent change strings conditionally + local tech_stack + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" + elif [[ -n "$escaped_lang" ]]; then + tech_stack="- $escaped_lang ($escaped_branch)" + elif [[ -n "$escaped_framework" ]]; then + tech_stack="- $escaped_framework ($escaped_branch)" + else + tech_stack="- ($escaped_branch)" + fi + + local recent_change + if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" + elif [[ -n "$escaped_lang" ]]; then + recent_change="- $escaped_branch: Added $escaped_lang" + elif [[ -n "$escaped_framework" ]]; then + recent_change="- $escaped_branch: Added $escaped_framework" + else + recent_change="- $escaped_branch: Added" + fi + + local substitutions=( + "s|\[PROJECT NAME\]|$project_name|" + "s|\[DATE\]|$current_date|" + "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" + "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" + "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" + "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" + "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" + ) + + for substitution in "${substitutions[@]}"; do + if ! sed -i.bak -e "$substitution" "$temp_file"; then + log_error "Failed to perform substitution: $substitution" + rm -f "$temp_file" "$temp_file.bak" + return 1 + fi + done + + # Convert \n sequences to actual newlines + newline=$(printf '\n') + sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" + + # Clean up backup files + rm -f "$temp_file.bak" "$temp_file.bak2" + + # Prepend Cursor frontmatter for .mdc files so rules are auto-included + if [[ "$target_file" == *.mdc ]]; then + local frontmatter_file + frontmatter_file=$(mktemp) || return 1 + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + + return 0 +} + + + + +update_existing_agent_file() { + local target_file="$1" + local current_date="$2" + + log_info "Updating existing agent context file..." + + # Use a single temporary file for atomic update + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + # Process the file in one pass + local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") + local new_tech_entries=() + local new_change_entry="" + + # Prepare new technology entries + if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then + new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then + new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") + fi + + # Prepare new change entry + if [[ -n "$tech_stack" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" + elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then + new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" + fi + + # Check if sections exist in the file + local has_active_technologies=0 + local has_recent_changes=0 + + if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then + has_active_technologies=1 + fi + + if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then + has_recent_changes=1 + fi + + # Process file line by line + local in_tech_section=false + local in_changes_section=false + local tech_entries_added=false + local changes_entries_added=false + local existing_changes_count=0 + local file_ended=false + + while IFS= read -r line || [[ -n "$line" ]]; do + # Handle Active Technologies section + if [[ "$line" == "## Active Technologies" ]]; then + echo "$line" >> "$temp_file" + in_tech_section=true + continue + elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + # Add new tech entries before closing the section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + in_tech_section=false + continue + elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then + # Add new tech entries before empty line in tech section + if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + echo "$line" >> "$temp_file" + continue + fi + + # Handle Recent Changes section + if [[ "$line" == "## Recent Changes" ]]; then + echo "$line" >> "$temp_file" + # Add new change entry right after the heading + if [[ -n "$new_change_entry" ]]; then + echo "$new_change_entry" >> "$temp_file" + fi + in_changes_section=true + changes_entries_added=true + continue + elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then + echo "$line" >> "$temp_file" + in_changes_section=false + continue + elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then + # Keep only first 2 existing changes + if [[ $existing_changes_count -lt 2 ]]; then + echo "$line" >> "$temp_file" + ((existing_changes_count++)) + fi + continue + fi + + # Update timestamp + if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then + echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" + else + echo "$line" >> "$temp_file" + fi + done < "$target_file" + + # Post-loop check: if we're still in the Active Technologies section and haven't added new entries + if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + # If sections don't exist, add them at the end of the file + if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then + echo "" >> "$temp_file" + echo "## Active Technologies" >> "$temp_file" + printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" + tech_entries_added=true + fi + + if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then + echo "" >> "$temp_file" + echo "## Recent Changes" >> "$temp_file" + echo "$new_change_entry" >> "$temp_file" + changes_entries_added=true + fi + + # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion + if [[ "$target_file" == *.mdc ]]; then + if ! head -1 "$temp_file" | grep -q '^---'; then + local frontmatter_file + frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" + cat "$temp_file" >> "$frontmatter_file" + mv "$frontmatter_file" "$temp_file" + fi + fi + + # Move temp file to target atomically + if ! mv "$temp_file" "$target_file"; then + log_error "Failed to update target file" + rm -f "$temp_file" + return 1 + fi + + return 0 +} +#============================================================================== +# Main Agent File Update Function +#============================================================================== + +update_agent_file() { + local target_file="$1" + local agent_name="$2" + + if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then + log_error "update_agent_file requires target_file and agent_name parameters" + return 1 + fi + + log_info "Updating $agent_name context file: $target_file" + + local project_name + project_name=$(basename "$REPO_ROOT") + local current_date + current_date=$(date +%Y-%m-%d) + + # Create directory if it doesn't exist + local target_dir + target_dir=$(dirname "$target_file") + if [[ ! -d "$target_dir" ]]; then + if ! mkdir -p "$target_dir"; then + log_error "Failed to create directory: $target_dir" + return 1 + fi + fi + + if [[ ! -f "$target_file" ]]; then + # Create new file from template + local temp_file + temp_file=$(mktemp) || { + log_error "Failed to create temporary file" + return 1 + } + + if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then + if mv "$temp_file" "$target_file"; then + log_success "Created new $agent_name context file" + else + log_error "Failed to move temporary file to $target_file" + rm -f "$temp_file" + return 1 + fi + else + log_error "Failed to create new agent file" + rm -f "$temp_file" + return 1 + fi + else + # Update existing file + if [[ ! -r "$target_file" ]]; then + log_error "Cannot read existing file: $target_file" + return 1 + fi + + if [[ ! -w "$target_file" ]]; then + log_error "Cannot write to existing file: $target_file" + return 1 + fi + + if update_existing_agent_file "$target_file" "$current_date"; then + log_success "Updated existing $agent_name context file" + else + log_error "Failed to update existing agent file" + return 1 + fi + fi + + return 0 +} + +#============================================================================== +# Agent Selection and Processing +#============================================================================== + +update_specific_agent() { + local agent_type="$1" + + case "$agent_type" in + claude) + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 + ;; + gemini) + update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 + ;; + copilot) + update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 + ;; + cursor-agent) + update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 + ;; + qwen) + update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 + ;; + opencode) + update_agent_file "$AGENTS_FILE" "opencode" || return 1 + ;; + codex) + update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 + ;; + windsurf) + update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 + ;; + junie) + update_agent_file "$JUNIE_FILE" "Junie" || return 1 + ;; + kilocode) + update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 + ;; + auggie) + update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 + ;; + roo) + update_agent_file "$ROO_FILE" "Roo Code" || return 1 + ;; + codebuddy) + update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 + ;; + qodercli) + update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 + ;; + amp) + update_agent_file "$AMP_FILE" "Amp" || return 1 + ;; + shai) + update_agent_file "$SHAI_FILE" "SHAI" || return 1 + ;; + tabnine) + update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 + ;; + kiro-cli) + update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 + ;; + agy) + update_agent_file "$AGY_FILE" "Antigravity" || return 1 + ;; + bob) + update_agent_file "$BOB_FILE" "IBM Bob" || return 1 + ;; + vibe) + update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 + ;; + kimi) + update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 + ;; + trae) + update_agent_file "$TRAE_FILE" "Trae" || return 1 + ;; + pi) + update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 + ;; + iflow) + update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 + ;; + generic) + log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." + ;; + *) + log_error "Unknown agent type '$agent_type'" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic" + exit 1 + ;; + esac +} + +# Helper: skip non-existent files and files already updated (dedup by +# realpath so that variables pointing to the same file — e.g. AMP_FILE, +# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). +# Uses a linear array instead of associative array for bash 3.2 compatibility. +# Note: defined at top level because bash 3.2 does not support true +# nested/local functions. _updated_paths, _found_agent, and _all_ok are +# initialised exclusively inside update_all_existing_agents so that +# sourcing this script has no side effects on the caller's environment. + +_update_if_new() { + local file="$1" name="$2" + [[ -f "$file" ]] || return 0 + local real_path + real_path=$(realpath "$file" 2>/dev/null || echo "$file") + local p + if [[ ${#_updated_paths[@]} -gt 0 ]]; then + for p in "${_updated_paths[@]}"; do + [[ "$p" == "$real_path" ]] && return 0 + done + fi + # Record the file as seen before attempting the update so that: + # (a) aliases pointing to the same path are not retried on failure + # (b) _found_agent reflects file existence, not update success + _updated_paths+=("$real_path") + _found_agent=true + update_agent_file "$file" "$name" +} + +update_all_existing_agents() { + _found_agent=false + _updated_paths=() + local _all_ok=true + + _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false + _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false + _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false + _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false + _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false + _update_if_new "$AMP_FILE" "Amp" || _all_ok=false + _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false + _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false + _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false + _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false + _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false + _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false + _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false + _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false + _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false + _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false + _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false + _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false + _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false + _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false + _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false + _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false + + # If no agent files exist, create a default Claude file + if [[ "$_found_agent" == false ]]; then + log_info "No existing agent files found, creating default Claude file..." + update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 + fi + + [[ "$_all_ok" == true ]] +} +print_summary() { + echo + log_info "Summary of changes:" + + if [[ -n "$NEW_LANG" ]]; then + echo " - Added language: $NEW_LANG" + fi + + if [[ -n "$NEW_FRAMEWORK" ]]; then + echo " - Added framework: $NEW_FRAMEWORK" + fi + + if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then + echo " - Added database: $NEW_DB" + fi + + echo + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]" +} + +#============================================================================== +# Main Execution +#============================================================================== + +main() { + # Validate environment before proceeding + validate_environment + + log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" + + # Parse the plan file to extract project information + if ! parse_plan_data "$NEW_PLAN"; then + log_error "Failed to parse plan data" + exit 1 + fi + + # Process based on agent type argument + local success=true + + if [[ -z "$AGENT_TYPE" ]]; then + # No specific agent provided - update all existing agent files + log_info "No agent specified, updating all existing agent files..." + if ! update_all_existing_agents; then + success=false + fi + else + # Specific agent provided - update only that agent + log_info "Updating specific agent: $AGENT_TYPE" + if ! update_specific_agent "$AGENT_TYPE"; then + success=false + fi + fi + + # Print summary + print_summary + + if [[ "$success" == true ]]; then + log_success "Agent context update completed successfully" + exit 0 + else + log_error "Agent context update completed with errors" + exit 1 + fi +} + +# Execute main function if script is run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.specify/templates/agent-file-template.md b/.specify/templates/agent-file-template.md new file mode 100644 index 000000000..4cc7fd667 --- /dev/null +++ b/.specify/templates/agent-file-template.md @@ -0,0 +1,28 @@ +# [PROJECT NAME] Development Guidelines + +Auto-generated from all feature plans. Last updated: [DATE] + +## Active Technologies + +[EXTRACTED FROM ALL PLAN.MD FILES] + +## Project Structure + +```text +[ACTUAL STRUCTURE FROM PLANS] +``` + +## Commands + +[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] + +## Code Style + +[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] + +## Recent Changes + +[LAST 3 FEATURES AND WHAT THEY ADDED] + + + diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 000000000..0caeacf8b --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 000000000..a4670ff46 --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md new file mode 100644 index 000000000..b539a5b12 --- /dev/null +++ b/.specify/templates/plan-template.md @@ -0,0 +1,104 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md new file mode 100644 index 000000000..f52815a83 --- /dev/null +++ b/.specify/templates/spec-template.md @@ -0,0 +1,128 @@ +# Feature Specification: [FEATURE NAME] + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md new file mode 100644 index 000000000..8accc1d72 --- /dev/null +++ b/.specify/templates/tasks-template.md @@ -0,0 +1,251 @@ +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 2 + +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready + +### Incremental Delivery + +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories + +### Parallel Team Strategy + +With multiple developers: + +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 430579801..af1818f6c 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -23223,6 +23223,139 @@ def _vercel_receipt_metadata_fits_preflight( return len(encoded) <= 16 * 1024 +_VERCEL_DEPLOYMENT_PENDING_STATES = frozenset( + {"INITIALIZING", "QUEUED", "BUILDING", "PENDING"} +) + + +def _vercel_async_deployment_metadata( + metadata: Mapping[str, object], + *, + deployment_id: str, + deployment_state: str, + pending: bool, +) -> dict: + return { + **dict(metadata), + "deployment_id": deployment_id, + "deployment_state": deployment_state, + "runtime_async_pending": pending, + "async_operation": { + "version": 1, + "operation_key": f"vercel:deployment:{deployment_id}", + "operation_id": deployment_id, + "state": deployment_state, + "poll": { + "tool": "vercel_deploy", + "arguments": { + "operation": "poll", + "deployment_id": deployment_id, + }, + "interval_ms": 2000, + }, + }, + } + + +def _vercel_deployment_state_outcome( + *, + deployment_id: str, + deployment_url: str | None, + deployment_state: str, + metadata: Mapping[str, object], +) -> ToolExecutionOutcome: + from urllib.parse import quote + + normalized_state = deployment_state.strip().upper() + pending = normalized_state in _VERCEL_DEPLOYMENT_PENDING_STATES + result_metadata = ( + _vercel_async_deployment_metadata( + metadata, + deployment_id=deployment_id, + deployment_state=normalized_state, + pending=pending, + ) + if pending or metadata.get("operation") == "deployment_status" + else dict(metadata) + ) + evidence_refs = ( + f"vercel-deployment://{quote(deployment_id, safe='')}", + ) + artifact_refs = (deployment_url,) if deployment_url is not None else () + if pending: + return _typed_pending( + f"Vercel deployment {deployment_id} is still {normalized_state}.", + metadata=result_metadata, + ) + if normalized_state == "READY": + if deployment_url is None: + return _typed_unknown( + "Vercel deployment reached READY without a stable HTTPS URL receipt.", + "vercel_deployment_status_invalid", + result_ref=deployment_id, + metadata=result_metadata, + ) + return _typed_success( + f"Vercel deployment {deployment_id} is READY at {deployment_url}.", + result_ref=deployment_id, + artifact_refs=artifact_refs, + evidence_refs=evidence_refs, + metadata=result_metadata, + ) + if normalized_state in {"ERROR", "CANCELED"}: + return ToolExecutionOutcome( + status="failed", + result_summary=( + f"Vercel deployment reached terminal state {normalized_state}." + ), + result_ref=deployment_id, + artifact_refs=artifact_refs, + evidence_refs=evidence_refs, + error_code=f"vercel_deployment_{normalized_state.lower()}", + metadata=result_metadata, + ) + return _typed_unknown( + f"Vercel deployment returned unknown state {normalized_state!r}.", + "vercel_deployment_status_unknown", + result_ref=deployment_id, + metadata=result_metadata, + ) + + +async def _get_vercel_deployment_state( + client, + *, + headers: Mapping[str, str], + deployment_id: str, +) -> tuple[str, str | None] | None: + from urllib.parse import quote + + try: + response = await client.get( + "https://api.vercel.com/v13/deployments/" + f"{quote(deployment_id, safe='')}", + headers=dict(headers), + ) + except Exception: + return None + if not 200 <= response.status_code < 300: + return None + data = _deploy_response_object(response) + if data is None or data.get("id") != deployment_id: + return ("UNKNOWN", None) + state_value = data.get("readyState") + if ( + not isinstance(state_value, str) + or not state_value.strip() + or len(state_value.strip().encode("utf-8")) > 100 + ): + return ("UNKNOWN", None) + return ( + state_value.strip().upper(), + _vercel_deployment_https_url(data.get("url")), + ) + + async def _vercel_deploy_outcome( agent_id: uuid.UUID, workspace_root: Path, @@ -23232,6 +23365,82 @@ async def _vercel_deploy_outcome( import httpx from urllib.parse import quote + operation_value = arguments.get("operation", "launch") + operation = ( + operation_value.strip().lower() + if isinstance(operation_value, str) + else "" + ) + if operation == "poll": + deployment_id_value = arguments.get("deployment_id") + deployment_id = ( + deployment_id_value.strip() + if isinstance(deployment_id_value, str) + else "" + ) + if ( + not deployment_id + or len(deployment_id.encode("utf-8")) > 512 + ): + return _typed_failure( + "vercel_deploy internal poll requires deployment_id.", + "invalid_tool_arguments", + ) + try: + token = await _get_vercel_token(agent_id, "vercel_deploy") + except Exception: + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=None, + deployment_state="UNKNOWN", + metadata={ + "provider": "vercel", + "operation": "deployment_status", + }, + ) + if not isinstance(token, str) or not token.strip(): + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=None, + deployment_state="UNKNOWN", + metadata={ + "provider": "vercel", + "operation": "deployment_status", + }, + ) + headers = {"Authorization": f"Bearer {token.strip()}"} + async with httpx.AsyncClient(timeout=60.0) as client: + observation = await _get_vercel_deployment_state( + client, + headers=headers, + deployment_id=deployment_id, + ) + if observation is None: + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=None, + deployment_state="PENDING", + metadata={ + "provider": "vercel", + "operation": "deployment_status", + }, + ) + deployment_state, deployment_url = observation + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=deployment_url, + deployment_state=deployment_state, + metadata={ + "provider": "vercel", + "operation": "deployment_status", + }, + ) + if operation != "launch": + return _typed_failure( + "vercel_deploy operation must be launch.", + "invalid_tool_arguments", + ) + project_value = arguments.get("project_name") method_value = arguments.get("deploy_method", "upload") repo_value = arguments.get("github_repo") @@ -23663,79 +23872,30 @@ def project_stage_failure( write_stage = None if deployment_state not in {"READY", "ERROR", "CANCELED"}: - try: - poll_response = await client.get( - "https://api.vercel.com/v13/deployments/" - f"{quote(deployment_id, safe='')}", - headers=headers, - ) - except Exception: - poll_response = None - poll_data = ( - _deploy_response_object(poll_response) - if poll_response is not None - and 200 <= poll_response.status_code < 300 - else None + observation = await _get_vercel_deployment_state( + client, + headers=headers, + deployment_id=deployment_id, ) - if ( - poll_data is not None - and poll_data.get("id") == deployment_id - and isinstance(poll_data.get("readyState"), str) - and str(poll_data.get("readyState")).strip() - and len( - str(poll_data.get("readyState")).strip().encode("utf-8") - ) - <= 100 - ): - deployment_state = str( - poll_data["readyState"] - ).strip().upper() - polled_url = poll_data.get("url") - normalized_polled_url = _vercel_deployment_https_url( - polled_url - ) - if normalized_polled_url is not None: - deployment_url = normalized_polled_url + if observation is not None: + deployment_state, polled_url = observation + if polled_url is not None: + deployment_url = polled_url metadata = receipt_metadata(operation="deployment_accepted") - evidence_refs = ( - f"vercel-deployment://{quote(deployment_id, safe='')}", - ) - if deployment_state in {"ERROR", "CANCELED"}: - return ToolExecutionOutcome( - status="failed", - result_summary=f"Vercel deployment reached terminal state {deployment_state}.", - result_ref=deployment_id, - artifact_refs=(deployment_url,), - evidence_refs=evidence_refs, - error_code=f"vercel_deployment_{deployment_state.lower()}", - metadata=metadata, - ) - if deployment_state == "READY": - return _typed_success( - f"Vercel deployment {deployment_id} is READY at {deployment_url}.", - result_ref=deployment_id, - artifact_refs=(deployment_url,), - evidence_refs=evidence_refs, - metadata=metadata, - ) - return _typed_success( - f"Vercel accepted deployment {deployment_id} at {deployment_url}; current state is {deployment_state}.", - result_ref=deployment_id, - artifact_refs=(deployment_url,), - evidence_refs=evidence_refs, + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=deployment_url, + deployment_state=deployment_state, metadata=metadata, ) except Exception as exc: if deployment_id and deployment_url: deployment_state = deployment_state or "PENDING" - return _typed_success( - f"Vercel accepted deployment {deployment_id}; status polling is pending.", - result_ref=deployment_id, - artifact_refs=(deployment_url,), - evidence_refs=( - f"vercel-deployment://{quote(deployment_id, safe='')}", - ), + return _vercel_deployment_state_outcome( + deployment_id=deployment_id, + deployment_url=deployment_url, + deployment_state=deployment_state, metadata=receipt_metadata(operation="deployment_accepted"), ) if write_stage: diff --git a/backend/tests/test_agent_tools_typed_vercel_deploy.py b/backend/tests/test_agent_tools_typed_vercel_deploy.py index 7fed85145..d0e447e47 100644 --- a/backend/tests/test_agent_tools_typed_vercel_deploy.py +++ b/backend/tests/test_agent_tools_typed_vercel_deploy.py @@ -288,6 +288,30 @@ def assert_confirmed_digests( assert outcome.metadata.get("confirmed_blob_digests") == list(expected) +def assert_async_deployment_operation( + outcome: ToolExecutionOutcome, + *, + state: str, + pending: bool, +) -> None: + assert outcome.metadata.get("runtime_async_pending") is pending + operation = outcome.metadata.get("async_operation") + assert operation == { + "version": 1, + "operation_key": f"vercel:deployment:{DEPLOYMENT_ID}", + "operation_id": DEPLOYMENT_ID, + "state": state, + "poll": { + "tool": "vercel_deploy", + "arguments": { + "operation": "poll", + "deployment_id": DEPLOYMENT_ID, + }, + "interval_ms": 2000, + }, + } + + def test_vercel_deploy_contract_is_typed_external_exactly_once() -> None: assert "vercel_deploy" in agent_tools.RUNTIME_TYPED_APPLICATION_TOOL_NAMES assert builtin_policy("vercel_deploy") == { @@ -742,7 +766,7 @@ async def test_deployment_post_rejects_non_https_artifact_receipt( @pytest.mark.asyncio -async def test_accepted_building_then_poll_timeout_is_successful_pending_receipt( +async def test_accepted_building_then_poll_timeout_is_async_pending_receipt( monkeypatch, tmp_path, ) -> None: @@ -777,10 +801,14 @@ async def test_accepted_building_then_poll_timeout_is_successful_pending_receipt } ) - outcome = assert_outcome(result, "succeeded") - assert outcome.result_ref == DEPLOYMENT_ID - assert DEPLOYMENT_URL in outcome.artifact_refs + outcome = assert_outcome(result, "pending") + assert outcome.result_ref is None assert outcome.metadata.get("deployment_state") in {"BUILDING", "PENDING"} + assert_async_deployment_operation( + outcome, + state=outcome.metadata["deployment_state"], + pending=True, + ) assert_confirmed_digests(outcome, [digest]) assert_deployment_posted_once(provider) assert provider.count("GET", f"/v13/deployments/{DEPLOYMENT_ID}") == 1 @@ -788,6 +816,101 @@ async def test_accepted_building_then_poll_timeout_is_successful_pending_receipt provider.assert_done() +@pytest.mark.parametrize( + ("provider_state", "expected_status"), + ( + ("INITIALIZING", "pending"), + ("QUEUED", "pending"), + ("BUILDING", "pending"), + ("READY", "succeeded"), + ("ERROR", "failed"), + ("CANCELED", "failed"), + ), +) +@pytest.mark.asyncio +async def test_internal_poll_maps_exact_deployment_without_replaying_launch( + monkeypatch, + tmp_path, + provider_state, + expected_status, +) -> None: + provider = ScriptedVercel( + ExpectedCall( + "GET", + f"/v13/deployments/{DEPLOYMENT_ID}", + FakeResponse(200, deployment_receipt(provider_state)), + ) + ) + install_vercel( + monkeypatch, + provider, + workspace_root=tmp_path / "missing-workspace", + ) + + result = await execute( + { + "operation": "poll", + "deployment_id": DEPLOYMENT_ID, + } + ) + + outcome = assert_outcome(result, expected_status) + assert outcome.metadata.get("deployment_state") == provider_state + assert_async_deployment_operation( + outcome, + state=provider_state, + pending=expected_status == "pending", + ) + assert provider.count("GET", f"/v13/deployments/{DEPLOYMENT_ID}") == 1 + assert provider.count("GET", f"/v9/projects/{PROJECT_NAME}") == 0 + assert provider.count("POST") == 0 + assert provider.count("PATCH") == 0 + provider.assert_done() + + +@pytest.mark.asyncio +async def test_internal_poll_does_not_accept_a_mismatched_deployment( + monkeypatch, + tmp_path, +) -> None: + provider = ScriptedVercel( + ExpectedCall( + "GET", + f"/v13/deployments/{DEPLOYMENT_ID}", + FakeResponse( + 200, + { + **deployment_receipt("READY"), + "id": "dpl_different", + }, + ), + ) + ) + install_vercel( + monkeypatch, + provider, + workspace_root=tmp_path / "missing-workspace", + ) + + result = await execute( + { + "operation": "poll", + "deployment_id": DEPLOYMENT_ID, + } + ) + + outcome = assert_outcome(result, "unknown") + assert outcome.error_code == "vercel_deployment_status_unknown" + assert_async_deployment_operation( + outcome, + state="UNKNOWN", + pending=False, + ) + assert provider.count("POST") == 0 + assert provider.count("PATCH") == 0 + provider.assert_done() + + @pytest.mark.parametrize( ("final_state", "expected_status"), ( diff --git a/specs/001-fix-vercel-async-wait/checklists/requirements.md b/specs/001-fix-vercel-async-wait/checklists/requirements.md new file mode 100644 index 000000000..ffb6dbe45 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Vercel Async Deployment Wait Recovery + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-05 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details beyond named external business states and existing system boundaries +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No NEEDS CLARIFICATION markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into the specification beyond necessary domain terminology + +## Notes + +- Validation passed in one review iteration. The feature is ready for implementation planning. diff --git a/specs/001-fix-vercel-async-wait/contracts/vercel-async-operation.md b/specs/001-fix-vercel-async-wait/contracts/vercel-async-operation.md new file mode 100644 index 000000000..4f83cf379 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/contracts/vercel-async-operation.md @@ -0,0 +1,48 @@ +# Contract: Vercel Declared Async Operation + +## Pending outcome + +```json +{ + "status": "pending", + "result_ref": null, + "metadata": { + "provider": "vercel", + "deployment_id": "dpl_xxx", + "deployment_state": "BUILDING", + "runtime_async_pending": true, + "async_operation": { + "version": 1, + "operation_id": "dpl_xxx", + "operation_key": "vercel:deployment:dpl_xxx", + "state": "BUILDING", + "poll": { + "tool": "vercel_deploy", + "arguments": { + "operation": "poll", + "deployment_id": "dpl_xxx" + }, + "interval_ms": 2000 + } + } + } +} +``` + +## Terminal outcome + +The terminal result MUST retain the same operation key and set `runtime_async_pending` to `false`. + +| Provider state | Tool status | +| --- | --- | +| `READY` | `succeeded` | +| `ERROR` | `failed` | +| `CANCELED` | `failed` | +| missing, unknown, or mismatched observation | `unknown` | + +## Prohibited behavior + +- A poll MUST NOT call any project-create, file-upload, repository-link, or deployment-create endpoint. +- A poll MUST NOT use project deployment-list results for settlement. +- A non-terminal state or transient status-read timeout MUST NOT produce `succeeded`. +- The contract MUST NOT require a Model-generated wait or polling Tool call. diff --git a/specs/001-fix-vercel-async-wait/data-model.md b/specs/001-fix-vercel-async-wait/data-model.md new file mode 100644 index 000000000..cbcd49c80 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/data-model.md @@ -0,0 +1,39 @@ +# Data Model: Vercel Async Deployment Wait Recovery + +No schema migration or new persistent entity is required. The feature uses the existing Tool execution +receipt and Runtime checkpoint. + +## Deployment Operation Receipt + +Represents the original Vercel deployment and every subsequent status observation. + +| Field | Meaning | Validation | +| --- | --- | --- | +| `operation_id` | Vercel deployment ID | Non-empty, stable across polls | +| `operation_key` | Runtime settlement correlation | Non-empty, stable for one deployment | +| `state` | Latest normalized Vercel `readyState` | Known non-terminal or terminal state | +| `poll.tool` | Internal Tool continuation | `vercel_deploy` | +| `poll.arguments.operation` | Execution mode | `poll` | +| `poll.arguments.deployment_id` | Exact deployment to read | Equals `operation_id` | +| `poll.interval_ms` | Next scheduled observation | `2000` | +| `runtime_async_pending` | Whether more observations are required | `true` for non-terminal, `false` for terminal | + +## State Transitions + +```text +INITIALIZING ─┐ +QUEUED ─┼─> pending ─> exact poll ─> pending or terminal +BUILDING ─┘ + +READY -> succeeded +ERROR -> failed +CANCELED -> failed +unknown or invalid observation -> unknown, never succeeded +``` + +## Relationships + +- One Agent Run contains the original deployment Tool execution. +- Each scheduled poll creates or consumes a Tool execution associated with the same Run. +- All receipts for one deployment share `operation_key`. +- A terminal poll atomically settles the current poll and prior pending receipts with that key. diff --git a/specs/001-fix-vercel-async-wait/plan.md b/specs/001-fix-vercel-async-wait/plan.md new file mode 100644 index 000000000..8584dcba1 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/plan.md @@ -0,0 +1,81 @@ +# Implementation Plan: Vercel Async Deployment Wait Recovery + +**Branch**: `001-fix-vercel-async-wait` | **Date**: 2026-08-05 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-fix-vercel-async-wait/spec.md` + +## Summary + +Change the existing Vercel Tool Adapter so accepted deployments in INITIALIZING, QUEUED, or BUILDING +return the Runtime's existing declared asynchronous-operation contract instead of a successful Tool +receipt. Add an internal poll mode that performs one exact deployment status read and reuses the same +operation identity until READY, ERROR, or CANCELED. Reuse all current Runtime scheduling, resume, +waiting, and terminal-settlement code without modification. + +## Technical Context + +**Language/Version**: Python 3.11 +**Primary Dependencies**: FastAPI service stack, httpx, SQLAlchemy async ORM, existing Agent Runtime +**Storage**: Existing PostgreSQL-backed `AgentToolExecution.result_metadata`; no migration +**Testing**: pytest, pytest-asyncio, existing scripted Vercel provider fixtures +**Target Platform**: Clawith backend service and Runtime worker +**Project Type**: Existing web-service backend +**Performance Goals**: One status GET per scheduled poll; no blocking sleep inside Tool execution +**Constraints**: Exactly one deployment POST; fixed 2-second interval; no new dependency; no generic +Runtime changes; no public Tool behavior expansion +**Scale/Scope**: One Vercel Tool Adapter, its typed-outcome tests, and one existing Runtime-contract +integration path + +## Constitution Check + +*GATE: Passed before research and re-checked after design.* + +- **Evidence Before Claims**: Current Vercel and Runtime code paths were inspected; the defect is the + Vercel outcome mapping and missing internal poll mode. +- **Minimal Scoped Changes**: Source changes are limited to `backend/app/services/agent_tools.py` and + scoped tests. Generic Runtime files are prohibited unless a failing contract test proves otherwise. +- **Contract and State Ownership**: Vercel maps `readyState`; Runtime consumes typed pending and + terminal outcomes. Model prose is not used for settlement. +- **Tests Prove Behavior**: Tests cover non-terminal mapping, repeated exact polling, terminal mapping, + original receipt settlement, and absence of duplicate deployment POSTs. +- **Preserve Existing Work**: Existing dirty files and ignored documentation remain untouched outside + the approved Spec Kit artifacts and Vercel bug-fix scope. + +Post-design re-check: PASS. The design adds no database entity, dependency, generic state machine, or +second scheduler. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-fix-vercel-async-wait/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── vercel-async-operation.md +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code + +```text +backend/ +├── app/services/agent_tools.py +└── tests/ + ├── test_agent_tools_typed_vercel_deploy.py + ├── test_agent_runtime_tool_step_service.py + └── test_agent_runtime_async_tool_poll.py +``` + +**Structure Decision**: Keep implementation inside the existing monolithic built-in Tool Adapter. +Reuse existing Runtime tests where possible; add only the smallest Vercel-specific integration +coverage needed to prove the generic contract consumes the new outcome. + +## Complexity Tracking + +No constitution violations or added architectural complexity. diff --git a/specs/001-fix-vercel-async-wait/quickstart.md b/specs/001-fix-vercel-async-wait/quickstart.md new file mode 100644 index 000000000..10012b68e --- /dev/null +++ b/specs/001-fix-vercel-async-wait/quickstart.md @@ -0,0 +1,39 @@ +# Quickstart: Verify the Vercel Async Wait Stopgap + +## 1. Run Vercel Adapter tests + +```bash +cd backend +.venv/bin/python -m pytest tests/test_agent_tools_typed_vercel_deploy.py +``` + +Verify that BUILDING produces a pending asynchronous operation, the internal poll path issues only an +exact deployment GET, READY succeeds, ERROR/CANCELED fail, and no poll repeats a deployment POST. + +## 2. Run Runtime contract tests + +```bash +cd backend +.venv/bin/python -m pytest \ + tests/test_agent_runtime_async_tool_poll.py \ + tests/test_agent_runtime_tool_step_service.py +``` + +Verify that the existing Runtime schedules the pending outcome and terminal settlement closes the +original receipt without changes to generic Runtime code. + +## 3. Run scoped static checks + +```bash +cd backend +.venv/bin/ruff check \ + app/services/agent_tools.py \ + tests/test_agent_tools_typed_vercel_deploy.py \ + tests/test_agent_runtime_async_tool_poll.py \ + tests/test_agent_runtime_tool_step_service.py +``` + +## 4. Diff boundary + +Confirm that production code changes are limited to the Vercel Tool Adapter. Generic Scheduler, +Resume, LangGraph wait, terminal settlement, and other Tool files must remain unchanged. diff --git a/specs/001-fix-vercel-async-wait/research.md b/specs/001-fix-vercel-async-wait/research.md new file mode 100644 index 000000000..f64eca6e7 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/research.md @@ -0,0 +1,54 @@ +# Research: Vercel Async Deployment Wait Recovery + +## Decision 1: Reuse the declared asynchronous Tool contract + +**Decision**: Emit the existing `runtime_async_pending + async_operation` metadata from +`vercel_deploy` for non-terminal provider states. + +**Rationale**: The Runtime already persists due times, schedules idempotent timer resumes, reconstructs +poll calls, and atomically settles all same-Run receipts sharing an operation key. + +**Alternatives considered**: + +- Restore a blocking `while + sleep` loop: rejected because it occupies a Tool worker and loses the + durable restart behavior introduced by the Runtime. +- Add a Vercel-specific scheduler: rejected because it duplicates an existing generic mechanism. +- Let the Model call `wait(external)`: rejected because that wait has no guaranteed pending operation + or resume producer. + +## Decision 2: Use the exact deployment identifier + +**Decision**: Poll `GET /v13/deployments/{deployment_id}` and keep one stable operation key derived +from that deployment identity. + +**Rationale**: The create response already supplies the identity. Project deployment lists cannot +prove which deployment belongs to the original Tool operation. + +**Alternatives considered**: + +- `vercel_list_deployments`: rejected because list ordering and concurrent deployments make the + correlation ambiguous. + +## Decision 3: Keep polling internal and fixed-interval + +**Decision**: The Runtime-generated poll invokes an internal `vercel_deploy` mode with the existing +two-second interval. The public Model-facing deployment request remains unchanged. + +**Rationale**: This is the smallest compatible change and prevents Model turns between polls. + +**Alternatives considered**: + +- Publicly expose a new status Tool or operation discriminator: rejected as unnecessary API expansion. +- Add adaptive backoff, deadlines, or cancellation: deferred because the approved scope is production + stopgap, not Runtime redesign. + +## Decision 4: Preserve provider truth at terminal settlement + +**Decision**: READY succeeds; ERROR and CANCELED fail; known non-terminal states remain pending. +Missing, unknown, or mismatched state never succeeds. + +**Rationale**: Acceptance of a deployment request is not proof that the deployment completed. + +**Alternatives considered**: + +- Treat accepted or BUILDING as success: rejected because it caused the reported stuck Run. diff --git a/specs/001-fix-vercel-async-wait/spec.md b/specs/001-fix-vercel-async-wait/spec.md new file mode 100644 index 000000000..42fa1e801 --- /dev/null +++ b/specs/001-fix-vercel-async-wait/spec.md @@ -0,0 +1,130 @@ +# Feature Specification: Vercel Async Deployment Wait Recovery + +**Feature Branch**: `001-fix-vercel-async-wait` +**Created**: 2026-08-05 +**Status**: Draft +**Input**: User description: "Fix the Vercel asynchronous deployment wait bug with the smallest +possible change. Keep non-terminal deployments pending, poll the exact deployment through the +existing durable Runtime, settle only on a provider terminal state, and never create a duplicate +deployment." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Receive the Final Deployment Result (Priority: P1) + +As a user who asks an Agent to deploy a project to Vercel, I receive a final response after the +specific deployment reaches a terminal state instead of seeing the Agent remain stuck waiting after +Vercel has completed the deployment. + +**Why this priority**: This is the reported production failure. A deployment can finish successfully +while the user never receives a completion response. + +**Independent Test**: Start one deployment that reports BUILDING before READY. The system must keep +the operation pending, check the same deployment again, settle it as successful, resume the same +Run, and make the final result available for the Agent response. + +**Acceptance Scenarios**: + +1. **Given** Vercel accepts a deployment and reports INITIALIZING, QUEUED, or BUILDING, **When** the + initial deployment call completes, **Then** the operation remains pending and the Run waits for + the existing Runtime polling mechanism. +2. **Given** the tracked deployment is pending, **When** Vercel later reports READY, **Then** the + original deployment operation succeeds and the same Run continues to its final response. +3. **Given** the tracked deployment is pending, **When** Vercel later reports ERROR or CANCELED, + **Then** the original deployment operation fails and the same Run continues through existing + failure handling. + +--- + +### User Story 2 - Avoid Duplicate Deployments (Priority: P2) + +As a user waiting for a deployment, I expect status checks to observe the deployment already created +for my request and never create additional deployments. + +**Why this priority**: Repeating an external write while polling can deploy stale or duplicate +versions and violates the existing exactly-once Tool contract. + +**Independent Test**: Exercise multiple non-terminal status checks followed by a terminal status and +verify that the provider receives exactly one create request while every status check uses the +original deployment identifier. + +**Acceptance Scenarios**: + +1. **Given** a deployment has already been created, **When** one or more Runtime polls execute, + **Then** each poll performs only an exact status read for the original deployment. +2. **Given** a poll is resumed after a process restart, **When** it executes, **Then** it uses the + persisted deployment identifier and does not repeat project creation, upload, repository linking, + or deployment creation. + +### Edge Cases + +- A status read times out after a stable deployment identifier has already been received; the + deployment must not be reported as successful solely because the create request was accepted. +- A successful status response omits a usable state, reports an unknown state, identifies a + different deployment, or lacks a valid deployment URL; the system must not fabricate success. +- Vercel reports READY immediately in the create response; the operation completes without entering + the asynchronous wait path. +- Vercel reports ERROR or CANCELED immediately; the operation fails without scheduling another poll. +- A project contains multiple deployments; list results must not settle the deployment operation + because only the exact deployment identifier is authoritative. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST treat INITIALIZING, QUEUED, and BUILDING as non-terminal deployment + states. +- **FR-002**: A non-terminal deployment MUST remain pending and include a stable operation identity, + exact deployment identity, and instructions for the existing Runtime polling mechanism. +- **FR-003**: Every status check MUST query the exact deployment created by the original request. +- **FR-004**: A status check MUST NOT create a project, upload files, link a repository, or create a + deployment. +- **FR-005**: READY MUST settle the original operation as successful. +- **FR-006**: ERROR and CANCELED MUST settle the original operation as failed. +- **FR-007**: A status timeout after receipt of a stable deployment identity MUST NOT settle the + operation as successful. +- **FR-008**: A missing, unknown, or mismatched provider state MUST NOT settle the operation as + successful. +- **FR-009**: All non-terminal checks for one deployment MUST retain the same operation identity so + the existing Runtime can settle the original operation at terminal completion. +- **FR-010**: Deployment list results MUST NOT settle or resume the original deployment operation. +- **FR-011**: The final terminal result MUST allow the same Run to continue and produce its existing + user-facing completion or failure response. +- **FR-012**: The change MUST reuse the existing asynchronous Runtime scheduling, waiting, resume, and + settlement behavior without changing generic wait behavior or other Tool contracts. + +### Key Entities + +- **Deployment Operation**: The single external deployment created for the user request, identified + by a stable provider deployment identity and a stable operation identity. +- **Deployment State Observation**: One exact observation of the tracked deployment, containing its + provider state and usable URL when available. +- **Agent Run**: The existing execution that initiated the deployment, waits while the operation is + pending, and continues after terminal settlement. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In all automated scenarios where a deployment progresses through one or more + non-terminal states to READY, the initiating Run resumes and reaches its final response. +- **SC-002**: Every tested deployment operation issues exactly one provider deployment-create + request, regardless of the number of status checks. +- **SC-003**: All tested provider terminal states map deterministically: READY succeeds, while ERROR + and CANCELED fail. +- **SC-004**: No tested non-terminal, missing, unknown, timed-out, or mismatched status is recorded as + successful. +- **SC-005**: Existing asynchronous Runtime regression tests continue to pass without behavior + changes outside the Vercel deployment path. + +## Assumptions + +- The existing durable Runtime scheduler, timer resume, waiting checkpoint, and operation settlement + contracts remain the authoritative implementation and already function for declared asynchronous + Tool operations. +- Polling uses the existing fixed two-second interval for this stopgap fix. +- General retry backoff, maximum retry counts, total operation deadlines, provider cancellation, + rejected-resume reclamation, and generic Model/Runtime wait conflicts remain out of scope. +- The public deployment request remains unchanged; polling is an internal continuation of an already + accepted operation. +- No other Tool behavior is changed unless a separate reproducible failure is established. diff --git a/specs/001-fix-vercel-async-wait/tasks.md b/specs/001-fix-vercel-async-wait/tasks.md new file mode 100644 index 000000000..d748d390b --- /dev/null +++ b/specs/001-fix-vercel-async-wait/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Vercel Async Deployment Wait Recovery + +**Input**: Design documents from `/specs/001-fix-vercel-async-wait/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: Regression tests are required by the feature specification and Constitution. + +## Phase 1: Baseline + +**Purpose**: Confirm the existing Vercel and generic Runtime contracts before editing production code. + +- [x] T001 Run the current scoped baseline in `backend/tests/test_agent_tools_typed_vercel_deploy.py`, `backend/tests/test_agent_runtime_async_tool_poll.py`, and `backend/tests/test_agent_runtime_tool_step_service.py` + +--- + +## Phase 2: User Story 1 - Receive the Final Deployment Result (Priority: P1) 🎯 MVP + +**Goal**: Keep non-terminal Vercel deployments pending and settle the original operation when the +exact deployment reaches READY, ERROR, or CANCELED. + +**Independent Test**: A scripted deployment progresses BUILDING → READY; the initial result is +pending, the internal poll reads the exact deployment, and the terminal result carries the same +operation key for existing Runtime settlement. + +### Tests for User Story 1 + +- [x] T002 [US1] Replace the accepted-BUILDING success expectation with pending-contract and internal-poll terminal cases in `backend/tests/test_agent_tools_typed_vercel_deploy.py` + +### Implementation for User Story 1 + +- [x] T003 [US1] Add the minimal Vercel provider-state helper, internal poll branch, pending outcome, and terminal operation metadata in `backend/app/services/agent_tools.py` +- [x] T004 [US1] Prove the existing Runtime consumes the Vercel pending and terminal contracts using scoped coverage in `backend/tests/test_agent_runtime_tool_step_service.py` or an existing equivalent test + +**Checkpoint**: BUILDING remains pending, READY succeeds, ERROR/CANCELED fail, and the original Run +can continue through the existing Runtime. + +--- + +## Phase 3: User Story 2 - Avoid Duplicate Deployments (Priority: P2) + +**Goal**: Ensure every continuation performs only an exact status read for the original deployment. + +**Independent Test**: Multiple internal polls issue zero project creates, uploads, repository links, +or deployment POSTs and always use the original deployment ID. + +### Tests for User Story 2 + +- [x] T005 [US2] Add assertions that internal polls perform only exact deployment GET requests and never repeat external writes in `backend/tests/test_agent_tools_typed_vercel_deploy.py` + +### Implementation for User Story 2 + +- [x] T006 [US2] Verify the internal poll discriminator branches before launch validation and all external write stages in `backend/app/services/agent_tools.py` + +**Checkpoint**: One user request produces exactly one Vercel deployment POST regardless of poll count. + +--- + +## Phase 4: Validation + +**Purpose**: Prove the stopgap and enforce the approved diff boundary. + +- [x] T007 Run scoped pytest for `backend/tests/test_agent_tools_typed_vercel_deploy.py`, `backend/tests/test_agent_runtime_async_tool_poll.py`, and `backend/tests/test_agent_runtime_tool_step_service.py` +- [x] T008 Run scoped Ruff on `backend/app/services/agent_tools.py` and modified test files, then verify generic Runtime production files are unchanged + +--- + +## Dependencies & Execution Order + +- T001 establishes the baseline. +- T002 must precede T003 so the regression is observable before implementation. +- T003 enables T004 and T005. +- T005 validates T006; both use the same source and test files, so they run sequentially. +- T007 and T008 run after all implementation tasks. + +## Implementation Strategy + +Implement only User Story 1 and User Story 2 as one minimal stopgap. Do not add a new scheduler, +deadline, backoff policy, cancellation path, public Tool, or generic wait rule. Stop if the existing +Runtime contract cannot consume the declared async outcome without production Runtime changes and +report that evidence before expanding scope. From 3ce7cfc4e7f5d4fcc2daf33030b315c3659c7768 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 18:02:25 +0800 Subject: [PATCH 02/53] Make configured schedule timezones trustworthy Tenant and Agent timezone values now enter the scheduler through one IANA validation boundary, while new tenants receive the confirmed Beijing default. Constraint: Agent timezone remains nullable to preserve tenant inheritance. Rejected: Validate against COMMON_TIMEZONES | it is a UI shortlist, not the complete IANA set. Confidence: high Scope-risk: narrow Directive: Do not reintroduce silent UTC fallback for configured Trigger timezones. Tested: backend/tests/test_timezone_validation.py (14 passed); Alembic single-head check. Not-tested: Migration execution against a production-sized tenants table. --- .../202608051200_default_tenant_timezone.py | 44 +++++++++++++++++ backend/app/api/tenants.py | 12 ++++- backend/app/models/tenant.py | 6 ++- backend/app/schemas/schemas.py | 11 ++++- backend/app/services/timezone_utils.py | 23 ++++++--- backend/tests/test_timezone_validation.py | 47 +++++++++++++++++++ 6 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/202608051200_default_tenant_timezone.py create mode 100644 backend/tests/test_timezone_validation.py diff --git a/backend/alembic/versions/202608051200_default_tenant_timezone.py b/backend/alembic/versions/202608051200_default_tenant_timezone.py new file mode 100644 index 000000000..96edf19da --- /dev/null +++ b/backend/alembic/versions/202608051200_default_tenant_timezone.py @@ -0,0 +1,44 @@ +"""Use Beijing as the required default tenant timezone. + +Revision ID: default_tenant_timezone +Revises: allow_checkpoint_deliveries +Create Date: 2026-08-05 12:00:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + + +revision: str = "default_tenant_timezone" +down_revision: str | None = "allow_checkpoint_deliveries" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "UPDATE tenants SET timezone = 'Asia/Shanghai' " + "WHERE timezone IS NULL OR btrim(timezone) = ''" + ) + op.alter_column( + "tenants", + "timezone", + existing_type=sa.String(length=50), + nullable=False, + server_default="Asia/Shanghai", + ) + + +def downgrade() -> None: + op.alter_column( + "tenants", + "timezone", + existing_type=sa.String(length=50), + nullable=True, + server_default="UTC", + ) diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index 7206fc585..024d7f46b 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from PIL import Image -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from sqlalchemy import func as sqla_func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +24,7 @@ from app.models.tenant import Tenant from app.models.user import User from app.services.storage import ensure_local_path, get_storage_backend, normalize_storage_key +from app.services.timezone_utils import validate_timezone_name router = APIRouter(prefix="/tenants", tags=["tenants"]) @@ -39,7 +40,7 @@ class TenantOut(BaseModel): name: str slug: str im_provider: str - timezone: str = "UTC" + timezone: str = "Asia/Shanghai" country_region: str = "001" is_active: bool sso_enabled: bool = False @@ -62,6 +63,13 @@ class TenantUpdate(BaseModel): sso_domain: str | None = None a2a_async_enabled: bool | None = None + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str | None) -> str: + if value is None: + raise ValueError("Tenant timezone is required") + return validate_timezone_name(value) + def _tenant_logo_key(tenant_id: uuid.UUID) -> str: return normalize_storage_key(f"_tenant_logos/{tenant_id}.png") diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index 01d47af11..f8743d130 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -38,7 +38,11 @@ class Tenant(Base): min_heartbeat_interval_minutes: Mapped[int] = mapped_column(Integer, default=240) # Default timezone for all agents in this company (IANA format, e.g. "Asia/Shanghai") - timezone: Mapped[str] = mapped_column(String(50), default="UTC") + timezone: Mapped[str] = mapped_column( + String(50), + default="Asia/Shanghai", + nullable=False, + ) # Company country/region code used to derive default timezone and business calendar. country_region: Mapped[str] = mapped_column(String(10), default="001") diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 11b354a01..3369ae7d2 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -3,7 +3,9 @@ import uuid from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.services.timezone_utils import validate_timezone_name # ─── Auth ─────────────────────────────────────────────── @@ -323,6 +325,13 @@ class AgentUpdate(BaseModel): timezone: str | None = None expires_at: datetime | None = None # Admin only — extend agent expiry + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str | None) -> str | None: + if value is None: + return None + return validate_timezone_name(value) + class AgentStatusOut(BaseModel): """Agent status from state.json.""" diff --git a/backend/app/services/timezone_utils.py b/backend/app/services/timezone_utils.py index 58e2fe1ed..db87f8bb1 100644 --- a/backend/app/services/timezone_utils.py +++ b/backend/app/services/timezone_utils.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import select @@ -32,11 +32,22 @@ "Pacific/Auckland", ] +DEFAULT_TIMEZONE = "Asia/Shanghai" + + +def validate_timezone_name(value: str) -> str: + """Return a valid IANA timezone name or raise a validation error.""" + try: + ZoneInfo(value) + except (ValueError, ZoneInfoNotFoundError) as error: + raise ValueError(f"Invalid IANA timezone: {value}") from error + return value + async def get_agent_timezone(agent_id: uuid.UUID) -> str: """Resolve effective timezone for an agent. - Priority: agent.timezone → tenant.timezone → 'UTC' + Priority: agent.timezone → tenant.timezone → default timezone. """ from app.models.agent import Agent from app.models.tenant import Tenant @@ -51,7 +62,7 @@ async def get_agent_timezone(agent_id: uuid.UUID) -> str: ) agent = result.scalar_one_or_none() if not agent: - return "UTC" + return DEFAULT_TIMEZONE # Agent-level override if agent.timezone: @@ -64,19 +75,19 @@ async def get_agent_timezone(agent_id: uuid.UUID) -> str: if tenant and tenant.timezone: return tenant.timezone - return "UTC" + return DEFAULT_TIMEZONE def get_agent_timezone_sync(agent, tenant=None) -> str: """Synchronous version — when agent and tenant objects are already loaded. - Priority: agent.timezone → tenant.timezone → 'UTC' + Priority: agent.timezone → tenant.timezone → default timezone. """ if agent.timezone: return agent.timezone if tenant and hasattr(tenant, 'timezone') and tenant.timezone: return tenant.timezone - return "UTC" + return DEFAULT_TIMEZONE def now_in_timezone(tz_name: str) -> datetime: diff --git a/backend/tests/test_timezone_validation.py b/backend/tests/test_timezone_validation.py new file mode 100644 index 000000000..a4a5922e2 --- /dev/null +++ b/backend/tests/test_timezone_validation.py @@ -0,0 +1,47 @@ +"""Timezone defaults and write-boundary validation.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.api.tenants import TenantOut, TenantUpdate +from app.models.tenant import Tenant +from app.schemas.schemas import AgentUpdate + + +def test_tenant_timezone_defaults_to_beijing() -> None: + assert Tenant.__table__.c.timezone.default.arg == "Asia/Shanghai" + assert TenantOut.model_fields["timezone"].default == "Asia/Shanghai" + + +@pytest.mark.parametrize("timezone_name", ["Asia/Shanghai", "America/New_York"]) +def test_tenant_update_accepts_iana_timezone(timezone_name: str) -> None: + assert TenantUpdate(timezone=timezone_name).timezone == timezone_name + + +@pytest.mark.parametrize("timezone_name", [None, "", "UTC+8", "Invalid/Timezone"]) +def test_tenant_update_rejects_missing_or_invalid_timezone( + timezone_name: str | None, +) -> None: + with pytest.raises(ValidationError): + TenantUpdate(timezone=timezone_name) + + +def test_tenant_update_allows_timezone_to_be_omitted() -> None: + update = TenantUpdate(name="Renamed") + + assert "timezone" not in update.model_dump(exclude_unset=True) + + +@pytest.mark.parametrize("timezone_name", [None, "Asia/Shanghai", "America/New_York"]) +def test_agent_update_accepts_inheritance_or_iana_timezone( + timezone_name: str | None, +) -> None: + assert AgentUpdate(timezone=timezone_name).timezone == timezone_name + + +@pytest.mark.parametrize("timezone_name", ["", "UTC+8", "Invalid/Timezone"]) +def test_agent_update_rejects_invalid_timezone(timezone_name: str) -> None: + with pytest.raises(ValidationError): + AgentUpdate(timezone=timezone_name) From ae867114d1290190df6d74076a20fff9ad6cd2b9 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 18:09:44 +0800 Subject: [PATCH 03/53] Preserve each scheduled occurrence across Trigger dispatch The evaluator now owns Cron occurrence calculation in the Agent effective timezone. The daemon, dispatch key, queue record, and Runtime source all consume that same planned instant, with a bounded 30-second grace and creation-time lower bound. Constraint: Keep cooldown_seconds and non-Cron Trigger behavior compatible. Rejected: Persist next_run_at or schedule_effective_from | the accepted design uses current rules plus a bounded grace without new schedule state. Confidence: high Scope-risk: moderate Directive: Do not derive Cron occurrences from last_fired_at or recompute them below evaluator. Tested: 22 scheduling, queue, intake, completion, and A2A tests; scoped Ruff. Not-tested: DST edge behavior and second-level Cron expressions are out of scope. --- backend/app/services/trigger_daemon.py | 7 +- .../app/services/trigger_runtime/dispatch.py | 5 +- .../app/services/trigger_runtime/evaluator.py | 82 +++++--- backend/app/services/trigger_runtime/keys.py | 33 +-- backend/app/services/trigger_runtime/queue.py | 8 +- backend/tests/test_trigger_runtime_queue.py | 11 + .../tests/test_trigger_runtime_scheduling.py | 196 ++++++++++++++++++ 7 files changed, 286 insertions(+), 56 deletions(-) create mode 100644 backend/tests/test_trigger_runtime_scheduling.py diff --git a/backend/app/services/trigger_daemon.py b/backend/app/services/trigger_daemon.py index 2445fc2f7..c4c42cd14 100644 --- a/backend/app/services/trigger_daemon.py +++ b/backend/app/services/trigger_daemon.py @@ -99,7 +99,7 @@ async def _handle_okr_report_trigger(trigger: AgentTrigger, now: datetime) -> bo async def _handle_okr_collection_trigger(trigger: AgentTrigger, now: datetime) -> bool: return await handle_okr_collection_trigger_runtime(trigger, now) -async def _evaluate_trigger(trigger: AgentTrigger, now: datetime) -> bool: +async def _evaluate_trigger(trigger: AgentTrigger, now: datetime) -> datetime | None: return await evaluate_trigger_runtime(trigger, now) # ── Main Tick Loop ────────────────────────────────────────────────── @@ -139,7 +139,8 @@ async def _tick(): continue try: - if await _evaluate_trigger(trigger, now): + scheduled_at = await _evaluate_trigger(trigger, now) + if scheduled_at is not None: handled = await _handle_okr_report_trigger(trigger, now) if not handled: handled = await _handle_okr_collection_trigger(trigger, now) @@ -166,7 +167,7 @@ async def _tick(): continue recent.append(now) _on_msg_fire_log[trigger.agent_id] = recent - await enqueue_due_trigger(trigger, now) + await enqueue_due_trigger(trigger, scheduled_at) except Exception as e: logger.warning(f"Error evaluating trigger {trigger.name}: {e}") diff --git a/backend/app/services/trigger_runtime/dispatch.py b/backend/app/services/trigger_runtime/dispatch.py index 0fd9d6ca9..4e39481d4 100644 --- a/backend/app/services/trigger_runtime/dispatch.py +++ b/backend/app/services/trigger_runtime/dispatch.py @@ -31,12 +31,13 @@ def runtime_execution_payload(trigger: AgentTrigger) -> dict: return payload -async def enqueue_due_trigger(trigger: AgentTrigger, now: datetime) -> None: +async def enqueue_due_trigger(trigger: AgentTrigger, scheduled_at: datetime) -> None: async with query_dao.session() as db: await enqueue_trigger_execution( db, trigger=trigger, source=trigger.type, - idempotency_key=build_scheduled_execution_key(trigger, now), + idempotency_key=build_scheduled_execution_key(trigger, scheduled_at), + scheduled_at=scheduled_at, payload_obj=runtime_execution_payload(trigger), ) diff --git a/backend/app/services/trigger_runtime/evaluator.py b/backend/app/services/trigger_runtime/evaluator.py index 711ac5ee0..c680af9c5 100644 --- a/backend/app/services/trigger_runtime/evaluator.py +++ b/backend/app/services/trigger_runtime/evaluator.py @@ -171,18 +171,27 @@ def is_private_url(url: str) -> bool: return True -async def evaluate_trigger(trigger: AgentTrigger, now: datetime) -> bool: +MISFIRE_GRACE = timedelta(seconds=30) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +async def evaluate_trigger(trigger: AgentTrigger, now: datetime) -> datetime | None: if not trigger.is_enabled: - return False + return None if trigger.expires_at and now >= trigger.expires_at: - return False + return None if trigger.max_fires is not None and trigger.fire_count >= trigger.max_fires: - return False + return None if trigger.last_fired_at: cooldown = timedelta(seconds=trigger.cooldown_seconds) if (now - trigger.last_fired_at) < cooldown: - return False + return None cfg = trigger.config or {} if isinstance(cfg, str): @@ -195,63 +204,68 @@ async def evaluate_trigger(trigger: AgentTrigger, now: datetime) -> bool: if t == "cron": expr = cfg.get("expr", "* * * * *") - base = trigger.last_fired_at or trigger.created_at try: - tz_name = cfg.get("timezone") - if not tz_name: - from app.services.timezone_utils import get_agent_timezone - tz_name = await get_agent_timezone(trigger.agent_id) + from app.services.timezone_utils import get_agent_timezone + + tz_name = await get_agent_timezone(trigger.agent_id) from zoneinfo import ZoneInfo - try: - tz = ZoneInfo(tz_name) - except (KeyError, Exception): - tz = ZoneInfo("UTC") + + tz = ZoneInfo(tz_name) local_now = now.astimezone(tz) - local_base = base.astimezone(tz) if base.tzinfo else base.replace(tzinfo=tz) - cron = croniter(expr, local_base) - next_run = cron.get_next(datetime) - if local_now >= next_run: - if await should_skip_non_workday(trigger, local_now): - await mark_trigger_skipped(trigger.id, now) - logger.info(f"[Trigger] Skipped {trigger.name} on non-workday {local_now.date()}") - return False - return True - return False + scheduled_at = croniter( + expr, + local_now + timedelta(microseconds=1), + ).get_prev(datetime) + scheduled_at_utc = _as_utc(scheduled_at) + now_utc = _as_utc(now) + created_at_utc = _as_utc(trigger.created_at) + if scheduled_at_utc <= created_at_utc: + return None + if scheduled_at_utc > now_utc: + return None + if now_utc - scheduled_at_utc > MISFIRE_GRACE: + return None + if await should_skip_non_workday(trigger, local_now): + await mark_trigger_skipped(trigger.id, now) + logger.info(f"[Trigger] Skipped {trigger.name} on non-workday {local_now.date()}") + return None + return scheduled_at except Exception as e: logger.warning(f"Invalid cron expr '{expr}' for trigger {trigger.name}: {e}") - return False + return None if t == "once": at_str = cfg.get("at") if not at_str: - return False + return None try: at = datetime.fromisoformat(at_str) if at.tzinfo is None: at = at.replace(tzinfo=timezone.utc) - return now >= at and trigger.fire_count == 0 + return at if now >= at and trigger.fire_count == 0 else None except Exception: - return False + return None if t == "interval": minutes = cfg.get("minutes", 30) base = trigger.last_fired_at or trigger.created_at - return (now - base) >= timedelta(minutes=minutes) + scheduled_at = base + timedelta(minutes=minutes) + return scheduled_at if now >= scheduled_at else None if t == "poll": interval_min = max(cfg.get("interval_min", 5), MIN_POLL_INTERVAL_MINUTES) base = trigger.last_fired_at or trigger.created_at if (now - base) < timedelta(minutes=interval_min): - return False - return await poll_check(trigger) + return None + return now if await poll_check(trigger) else None if t == "on_message": - return await check_new_agent_messages(trigger) + return now if await check_new_agent_messages(trigger) else None if t == "webhook": - return False + return None - return False + return None async def poll_check(trigger: AgentTrigger) -> bool: diff --git a/backend/app/services/trigger_runtime/keys.py b/backend/app/services/trigger_runtime/keys.py index 4fbb3178a..4261f256a 100644 --- a/backend/app/services/trigger_runtime/keys.py +++ b/backend/app/services/trigger_runtime/keys.py @@ -3,14 +3,15 @@ from __future__ import annotations import hashlib -from datetime import datetime, timedelta, timezone - -from croniter import croniter +from datetime import datetime, timezone from app.models.trigger import AgentTrigger -def build_scheduled_execution_key(trigger: AgentTrigger, now: datetime) -> str: +def build_scheduled_execution_key( + trigger: AgentTrigger, + scheduled_at: datetime, +) -> str: """Build a deterministic idempotency key for non-webhook trigger runs.""" cfg = trigger.config or {} trigger_type = trigger.type @@ -19,19 +20,16 @@ def build_scheduled_execution_key(trigger: AgentTrigger, now: datetime) -> str: return f"once:{trigger.id}:{cfg.get('at', '')}" if trigger_type == "interval": - minutes = int(cfg.get("minutes", 30) or 30) - base = trigger.last_fired_at or trigger.created_at - due_at = base + timedelta(minutes=minutes) - return f"interval:{trigger.id}:{due_at.astimezone(timezone.utc).isoformat()}" + return ( + f"interval:{trigger.id}:" + f"{scheduled_at.astimezone(timezone.utc).isoformat()}" + ) if trigger_type == "cron": - expr = cfg.get("expr", "* * * * *") - base = trigger.last_fired_at or trigger.created_at - cron = croniter(expr, base) - due_at = cron.get_next(datetime) - if due_at.tzinfo is None: - due_at = due_at.replace(tzinfo=timezone.utc) - return f"cron:{trigger.id}:{due_at.astimezone(timezone.utc).isoformat()}" + return ( + f"cron:{trigger.id}:" + f"{scheduled_at.astimezone(timezone.utc).isoformat()}" + ) if trigger_type == "on_message": matched_from = str(cfg.get("_matched_from") or "") @@ -44,4 +42,7 @@ def build_scheduled_execution_key(trigger: AgentTrigger, now: datetime) -> str: digest = hashlib.sha256(current_value.encode("utf-8")).hexdigest() return f"poll:{trigger.id}:{digest}" - return f"{trigger_type}:{trigger.id}:{now.replace(microsecond=0).isoformat()}" + return ( + f"{trigger_type}:{trigger.id}:" + f"{scheduled_at.replace(microsecond=0).isoformat()}" + ) diff --git a/backend/app/services/trigger_runtime/queue.py b/backend/app/services/trigger_runtime/queue.py index 5aed37903..4a05d6d0e 100644 --- a/backend/app/services/trigger_runtime/queue.py +++ b/backend/app/services/trigger_runtime/queue.py @@ -63,12 +63,18 @@ async def enqueue_trigger_execution( trigger: AgentTrigger, source: str, idempotency_key: str, + scheduled_at: datetime | None = None, payload_text: str = "", payload_obj: dict | None = None, ) -> tuple[TriggerExecution | None, bool]: """Atomically insert an occurrence and its required Runtime command.""" normalized_key = idempotency_key[:255] now = datetime.now(timezone.utc) + scheduled_at_utc = scheduled_at or now + if scheduled_at_utc.tzinfo is None: + scheduled_at_utc = scheduled_at_utc.replace(tzinfo=timezone.utc) + else: + scheduled_at_utc = scheduled_at_utc.astimezone(timezone.utc) execution = TriggerExecution( id=uuid.uuid4(), trigger_id=trigger.id, @@ -78,7 +84,7 @@ async def enqueue_trigger_execution( idempotency_key=normalized_key, payload=payload_obj if isinstance(payload_obj, dict) else {}, payload_text=payload_text[:8000], - scheduled_at=now, + scheduled_at=scheduled_at_utc, ) try: async with db.begin_nested(): diff --git a/backend/tests/test_trigger_runtime_queue.py b/backend/tests/test_trigger_runtime_queue.py index cae556e28..3410c3d77 100644 --- a/backend/tests/test_trigger_runtime_queue.py +++ b/backend/tests/test_trigger_runtime_queue.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import uuid +from datetime import UTC, datetime, timedelta, timezone import pytest from app.models.agent import Agent @@ -108,11 +109,20 @@ async def accept_runtime(*_args, **kwargs): side_effect=accept_runtime, ), ): + scheduled_at = datetime( + 2026, + 8, + 5, + 9, + 0, + tzinfo=timezone(timedelta(hours=8)), + ) execution, created = await enqueue_trigger_execution( db, # type: ignore[arg-type] trigger=trigger, source="poll", idempotency_key="poll:2026-07-13T16:00", + scheduled_at=scheduled_at, ) assert created is True @@ -123,6 +133,7 @@ async def accept_runtime(*_args, **kwargs): assert db.added == [execution] assert trigger.fire_count == 1 assert trigger.last_fired_at is not None + assert execution.scheduled_at == scheduled_at.astimezone(UTC) @pytest.mark.asyncio diff --git a/backend/tests/test_trigger_runtime_scheduling.py b/backend/tests/test_trigger_runtime_scheduling.py new file mode 100644 index 000000000..3828b99a1 --- /dev/null +++ b/backend/tests/test_trigger_runtime_scheduling.py @@ -0,0 +1,196 @@ +"""Scheduled occurrence ownership across evaluator and dispatch.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch +import uuid +from zoneinfo import ZoneInfo + +import pytest + +from app.models.trigger import AgentTrigger +from app.services.trigger_runtime.dispatch import enqueue_due_trigger +from app.services.trigger_runtime.evaluator import evaluate_trigger +from app.services.trigger_runtime.keys import build_scheduled_execution_key + + +def _cron_trigger( + *, + created_at: datetime, + last_fired_at: datetime | None = None, + config: dict | None = None, +) -> AgentTrigger: + return AgentTrigger( + id=uuid.uuid4(), + agent_id=uuid.uuid4(), + name="daily-check", + type="cron", + config=config or {"expr": "0 9 * * *"}, + reason="Daily check", + is_enabled=True, + created_at=created_at, + last_fired_at=last_fired_at, + fire_count=0, + cooldown_seconds=60, + ) + + +@pytest.mark.asyncio +async def test_cron_evaluator_returns_agent_local_occurrence() -> None: + now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) + trigger = _cron_trigger(created_at=now - timedelta(days=2)) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Asia/Shanghai"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert scheduled_at == datetime( + 2026, + 8, + 5, + 9, + 0, + tzinfo=ZoneInfo("Asia/Shanghai"), + ) + + +@pytest.mark.asyncio +async def test_cron_evaluator_ignores_trigger_timezone_override() -> None: + now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) + trigger = _cron_trigger( + created_at=now - timedelta(days=2), + config={"expr": "0 9 * * *", "timezone": "America/New_York"}, + ) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Asia/Shanghai"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert scheduled_at is not None + assert scheduled_at.tzinfo == ZoneInfo("Asia/Shanghai") + assert scheduled_at.astimezone(UTC) == datetime(2026, 8, 5, 1, 0, tzinfo=UTC) + + +@pytest.mark.asyncio +async def test_cron_occurrence_does_not_drift_with_last_fired_at() -> None: + now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) + trigger = _cron_trigger( + created_at=now - timedelta(days=3), + last_fired_at=datetime(2026, 8, 4, 1, 5, tzinfo=UTC), + ) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Asia/Shanghai"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert scheduled_at is not None + assert scheduled_at.astimezone(UTC) == datetime(2026, 8, 5, 1, 0, tzinfo=UTC) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delay_seconds, expected_due", [(30, True), (31, False)]) +async def test_cron_evaluator_applies_thirty_second_grace( + delay_seconds: int, + expected_due: bool, +) -> None: + now = datetime(2026, 8, 5, 1, 0, delay_seconds, tzinfo=UTC) + trigger = _cron_trigger(created_at=now - timedelta(days=2)) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Asia/Shanghai"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert (scheduled_at is not None) is expected_due + + +@pytest.mark.asyncio +async def test_cron_evaluator_rejects_occurrence_before_trigger_creation() -> None: + now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) + trigger = _cron_trigger( + created_at=datetime(2026, 8, 5, 1, 0, 5, tzinfo=UTC), + ) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Asia/Shanghai"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert scheduled_at is None + + +@pytest.mark.asyncio +async def test_cron_evaluator_does_not_fallback_for_invalid_timezone() -> None: + now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) + trigger = _cron_trigger(created_at=now - timedelta(days=2)) + + with patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Invalid/Timezone"), + ): + scheduled_at = await evaluate_trigger(trigger, now) + + assert scheduled_at is None + + +def test_cron_execution_key_uses_supplied_occurrence() -> None: + scheduled_at = datetime( + 2026, + 8, + 5, + 9, + 0, + tzinfo=ZoneInfo("Asia/Shanghai"), + ) + trigger = _cron_trigger(created_at=scheduled_at - timedelta(days=2)) + + key = build_scheduled_execution_key(trigger, scheduled_at) + + assert key == f"cron:{trigger.id}:2026-08-05T01:00:00+00:00" + + +class _SessionContext: + async def __aenter__(self): + return MagicMock() + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +@pytest.mark.asyncio +async def test_dispatch_passes_occurrence_to_queue_unchanged() -> None: + scheduled_at = datetime( + 2026, + 8, + 5, + 9, + 0, + tzinfo=ZoneInfo("Asia/Shanghai"), + ) + trigger = _cron_trigger(created_at=scheduled_at - timedelta(days=2)) + + with ( + patch( + "app.services.trigger_runtime.dispatch.async_session", + return_value=_SessionContext(), + ), + patch( + "app.services.trigger_runtime.dispatch.enqueue_trigger_execution", + new=AsyncMock(), + ) as enqueue, + ): + await enqueue_due_trigger(trigger, scheduled_at) + + assert enqueue.await_args.kwargs["scheduled_at"] is scheduled_at + assert enqueue.await_args.kwargs["idempotency_key"] == ( + f"cron:{trigger.id}:2026-08-05T01:00:00+00:00" + ) From d576c5823890867893fdddfeeda172d7eb604af6 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 18:12:46 +0800 Subject: [PATCH 04/53] Keep failed scheduled occurrences eligible within grace Scheduled Trigger intake failures now roll back the occurrence instead of persisting a failed receipt that blocks the next daemon scan. Structured logs retain the failure evidence, while webhook receipts preserve their existing synchronous failure contract. Constraint: Retry is only the ordinary 15-second scan while the occurrence remains inside the 30-second grace. Rejected: Add retry counters, backoff jobs, or new schedule fields | outside the confirmed minimal repair. Confidence: high Scope-risk: narrow Directive: A failed scheduled intake must not consume its occurrence identity. Tested: 40 timezone, scheduling, queue, Runtime, A2A, and webhook tests; scoped Ruff. Not-tested: Process termination during the database commit syscall. --- .../app/services/trigger_runtime/dispatch.py | 27 +++++-- .../app/services/trigger_runtime/evaluator.py | 9 ++- backend/app/services/trigger_runtime/queue.py | 44 ++++++++--- backend/tests/test_trigger_runtime_queue.py | 79 ++++++++++++++----- .../tests/test_trigger_runtime_scheduling.py | 60 +++++++++++++- 5 files changed, 178 insertions(+), 41 deletions(-) diff --git a/backend/app/services/trigger_runtime/dispatch.py b/backend/app/services/trigger_runtime/dispatch.py index 4e39481d4..eccc491cf 100644 --- a/backend/app/services/trigger_runtime/dispatch.py +++ b/backend/app/services/trigger_runtime/dispatch.py @@ -4,6 +4,8 @@ from datetime import datetime +from loguru import logger + from app.dao import query_dao from app.models.trigger import AgentTrigger from app.services.trigger_runtime.keys import build_scheduled_execution_key @@ -33,11 +35,20 @@ def runtime_execution_payload(trigger: AgentTrigger) -> dict: async def enqueue_due_trigger(trigger: AgentTrigger, scheduled_at: datetime) -> None: async with query_dao.session() as db: - await enqueue_trigger_execution( - db, - trigger=trigger, - source=trigger.type, - idempotency_key=build_scheduled_execution_key(trigger, scheduled_at), - scheduled_at=scheduled_at, - payload_obj=runtime_execution_payload(trigger), - ) + try: + await enqueue_trigger_execution( + db, + trigger=trigger, + source=trigger.type, + idempotency_key=build_scheduled_execution_key(trigger, scheduled_at), + scheduled_at=scheduled_at, + payload_obj=runtime_execution_payload(trigger), + ) + except Exception as error: + logger.bind( + trigger_id=str(trigger.id), + trigger_name=trigger.name, + trigger_type=trigger.type, + scheduled_at=scheduled_at.isoformat(), + ).error("Trigger occurrence registration failed: {}", error) + raise diff --git a/backend/app/services/trigger_runtime/evaluator.py b/backend/app/services/trigger_runtime/evaluator.py index c680af9c5..3ad11795e 100644 --- a/backend/app/services/trigger_runtime/evaluator.py +++ b/backend/app/services/trigger_runtime/evaluator.py @@ -230,8 +230,13 @@ async def evaluate_trigger(trigger: AgentTrigger, now: datetime) -> datetime | N logger.info(f"[Trigger] Skipped {trigger.name} on non-workday {local_now.date()}") return None return scheduled_at - except Exception as e: - logger.warning(f"Invalid cron expr '{expr}' for trigger {trigger.name}: {e}") + except Exception as error: + logger.bind( + trigger_id=str(trigger.id), + trigger_name=trigger.name, + trigger_type=trigger.type, + cron_expr=expr, + ).warning("Trigger occurrence evaluation failed: {}", error) return None if t == "once": diff --git a/backend/app/services/trigger_runtime/queue.py b/backend/app/services/trigger_runtime/queue.py index 4a05d6d0e..f6604578f 100644 --- a/backend/app/services/trigger_runtime/queue.py +++ b/backend/app/services/trigger_runtime/queue.py @@ -57,6 +57,20 @@ def _fail_runtime_execution( execution.last_error = f"{error.code}: {error}"[:2000] +async def _handle_intake_failure( + db: AsyncSession, + *, + execution: TriggerExecution, + error: TriggerRuntimeIntakeError, + now: datetime, + persist_intake_failure: bool, +) -> None: + if not persist_intake_failure: + await db.rollback() + raise error + _fail_runtime_execution(execution, error, now) + + async def enqueue_trigger_execution( db: AsyncSession, *, @@ -64,6 +78,7 @@ async def enqueue_trigger_execution( source: str, idempotency_key: str, scheduled_at: datetime | None = None, + persist_intake_failure: bool = False, payload_text: str = "", payload_obj: dict | None = None, ) -> tuple[TriggerExecution | None, bool]: @@ -112,25 +127,29 @@ async def enqueue_trigger_execution( "Trigger disappeared while its execution was being registered", ) if not stored_trigger.is_enabled: - _fail_runtime_execution( - execution, - TriggerRuntimeIntakeError( + await _handle_intake_failure( + db, + execution=execution, + error=TriggerRuntimeIntakeError( "trigger_disabled", "Trigger was disabled before its execution was accepted", ), - now, + now=now, + persist_intake_failure=persist_intake_failure, ) await db.commit() return execution, True agent: Agent | None = await load_trigger_agent(db, trigger=stored_trigger) if agent is None: - _fail_runtime_execution( - execution, - TriggerRuntimeIntakeError( + await _handle_intake_failure( + db, + execution=execution, + error=TriggerRuntimeIntakeError( "agent_not_found", "Runtime Trigger Agent does not exist", ), - now, + now=now, + persist_intake_failure=persist_intake_failure, ) else: try: @@ -153,7 +172,13 @@ async def enqueue_trigger_execution( _mark_trigger_fired(stored_trigger, now) await db.flush() except TriggerRuntimeIntakeError as error: - _fail_runtime_execution(execution, error, now) + await _handle_intake_failure( + db, + execution=execution, + error=error, + now=now, + persist_intake_failure=persist_intake_failure, + ) await db.commit() return execution, True @@ -186,6 +211,7 @@ async def enqueue_webhook_execution( trigger=trigger, source="webhook", idempotency_key=delivery_key, + persist_intake_failure=True, payload_text=payload_text, payload_obj=payload_obj, ) diff --git a/backend/tests/test_trigger_runtime_queue.py b/backend/tests/test_trigger_runtime_queue.py index 3410c3d77..5c6552e0d 100644 --- a/backend/tests/test_trigger_runtime_queue.py +++ b/backend/tests/test_trigger_runtime_queue.py @@ -38,6 +38,7 @@ def __init__(self, stored_trigger: AgentTrigger) -> None: self.nested = 0 self.flushes = 0 self.commits = 0 + self.rollbacks = 0 def begin_nested(self) -> _Nested: self.nested += 1 @@ -55,6 +56,9 @@ async def execute(self, _statement) -> _ScalarResult: async def commit(self) -> None: self.commits += 1 + async def rollback(self) -> None: + self.rollbacks += 1 + def _records() -> tuple[AgentTrigger, Agent]: agent_id = uuid.uuid4() @@ -137,7 +141,7 @@ async def accept_runtime(*_args, **kwargs): @pytest.mark.asyncio -async def test_runtime_intake_rejection_settles_occurrence_without_legacy_fallback() -> None: +async def test_runtime_intake_rejection_rolls_back_scheduled_occurrence() -> None: trigger, agent = _records() db = _QueueSession(trigger) error = TriggerRuntimeIntakeError( @@ -155,24 +159,23 @@ async def test_runtime_intake_rejection_settles_occurrence_without_legacy_fallba new=AsyncMock(side_effect=error), ), ): - execution, created = await enqueue_trigger_execution( - db, # type: ignore[arg-type] - trigger=trigger, - source="poll", - idempotency_key="poll:2026-07-13T16:00", - ) - - assert created is True - assert execution is not None - assert execution.status == "failed" - assert execution.last_error == "agent_model_missing: Runtime Trigger Agent has no primary model" - assert execution.finished_at is not None + with pytest.raises(TriggerRuntimeIntakeError) as raised: + await enqueue_trigger_execution( + db, # type: ignore[arg-type] + trigger=trigger, + source="poll", + idempotency_key="poll:2026-07-13T16:00", + ) + + assert raised.value.code == "agent_model_missing" assert trigger.fire_count == 0 - assert db.commits == 1 + assert trigger.last_fired_at is None + assert db.commits == 0 + assert db.rollbacks == 1 @pytest.mark.asyncio -async def test_runtime_disabled_settles_occurrence_without_legacy_claiming() -> None: +async def test_runtime_disabled_rolls_back_scheduled_occurrence() -> None: trigger, agent = _records() db = _QueueSession(trigger) @@ -185,18 +188,56 @@ async def test_runtime_disabled_settles_occurrence_without_legacy_claiming() -> "app.services.trigger_runtime.queue.enqueue_trigger_runtime", new=AsyncMock(return_value=None), ), + ): + with pytest.raises(TriggerRuntimeIntakeError) as raised: + await enqueue_trigger_execution( + db, # type: ignore[arg-type] + trigger=trigger, + source="poll", + idempotency_key="poll:2026-07-13T16:00", + ) + + assert raised.value.code == "runtime_v2_disabled" + assert trigger.fire_count == 0 + assert trigger.last_fired_at is None + assert db.commits == 0 + assert db.rollbacks == 1 + + +@pytest.mark.asyncio +async def test_webhook_intake_rejection_keeps_failure_receipt() -> None: + trigger, agent = _records() + trigger.type = "webhook" + db = _QueueSession(trigger) + error = TriggerRuntimeIntakeError( + "agent_model_missing", + "Runtime Trigger Agent has no primary model", + ) + + with ( + patch( + "app.services.trigger_runtime.queue.load_trigger_agent", + new=AsyncMock(return_value=agent), + ), + patch( + "app.services.trigger_runtime.queue.enqueue_trigger_runtime", + new=AsyncMock(side_effect=error), + ), ): execution, created = await enqueue_trigger_execution( db, # type: ignore[arg-type] trigger=trigger, - source="poll", - idempotency_key="poll:2026-07-13T16:00", + source="webhook", + idempotency_key="delivery-1", + persist_intake_failure=True, ) assert created is True assert execution is not None assert execution.status == "failed" - assert execution.last_error is not None - assert execution.last_error.startswith("runtime_v2_disabled:") + assert execution.last_error == ( + "agent_model_missing: Runtime Trigger Agent has no primary model" + ) assert trigger.fire_count == 0 assert db.commits == 1 + assert db.rollbacks == 0 diff --git a/backend/tests/test_trigger_runtime_scheduling.py b/backend/tests/test_trigger_runtime_scheduling.py index 3828b99a1..a64a8a759 100644 --- a/backend/tests/test_trigger_runtime_scheduling.py +++ b/backend/tests/test_trigger_runtime_scheduling.py @@ -132,14 +132,28 @@ async def test_cron_evaluator_rejects_occurrence_before_trigger_creation() -> No async def test_cron_evaluator_does_not_fallback_for_invalid_timezone() -> None: now = datetime(2026, 8, 5, 1, 0, 8, tzinfo=UTC) trigger = _cron_trigger(created_at=now - timedelta(days=2)) + bound_logger = MagicMock() - with patch( - "app.services.timezone_utils.get_agent_timezone", - new=AsyncMock(return_value="Invalid/Timezone"), + with ( + patch( + "app.services.timezone_utils.get_agent_timezone", + new=AsyncMock(return_value="Invalid/Timezone"), + ), + patch( + "app.services.trigger_runtime.evaluator.logger.bind", + return_value=bound_logger, + ) as bind, ): scheduled_at = await evaluate_trigger(trigger, now) assert scheduled_at is None + bind.assert_called_once_with( + trigger_id=str(trigger.id), + trigger_name=trigger.name, + trigger_type=trigger.type, + cron_expr="0 9 * * *", + ) + bound_logger.warning.assert_called_once() def test_cron_execution_key_uses_supplied_occurrence() -> None: @@ -194,3 +208,43 @@ async def test_dispatch_passes_occurrence_to_queue_unchanged() -> None: assert enqueue.await_args.kwargs["idempotency_key"] == ( f"cron:{trigger.id}:2026-08-05T01:00:00+00:00" ) + + +@pytest.mark.asyncio +async def test_dispatch_logs_scheduled_occurrence_registration_failure() -> None: + scheduled_at = datetime( + 2026, + 8, + 5, + 9, + 0, + tzinfo=ZoneInfo("Asia/Shanghai"), + ) + trigger = _cron_trigger(created_at=scheduled_at - timedelta(days=2)) + error = RuntimeError("database unavailable") + bound_logger = MagicMock() + + with ( + patch( + "app.services.trigger_runtime.dispatch.async_session", + return_value=_SessionContext(), + ), + patch( + "app.services.trigger_runtime.dispatch.enqueue_trigger_execution", + new=AsyncMock(side_effect=error), + ), + patch( + "app.services.trigger_runtime.dispatch.logger.bind", + return_value=bound_logger, + ) as bind, + pytest.raises(RuntimeError, match="database unavailable"), + ): + await enqueue_due_trigger(trigger, scheduled_at) + + bind.assert_called_once_with( + trigger_id=str(trigger.id), + trigger_name=trigger.name, + trigger_type=trigger.type, + scheduled_at=scheduled_at.isoformat(), + ) + bound_logger.error.assert_called_once() From 2c3b3ecd95006d3464710d982af14197a6e55d5a Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 19:10:58 +0800 Subject: [PATCH 05/53] Keep invalid Cron edits out of scheduled evaluation Cron creation already rejected malformed expressions, but both existing update boundaries could persist them. Validate before mutation and commit while preserving REST replacement and Agent-tool patch semantics. Constraint: Preserve current Trigger APIs and config compatibility Rejected: Introduce a shared Trigger config abstraction | interface unification remains deferred Confidence: high Scope-risk: narrow Reversibility: clean Directive: Trigger config timezone may remain stored for compatibility but must not define occurrence timezone Tested: Full backend pytest 2162 passed; focused Trigger tests 18 passed; scoped Ruff and git diff checks passed Not-tested: DST-specific and second-level Cron behavior remain out of scope --- backend/app/api/triggers.py | 15 ++ backend/app/services/agent_tools.py | 18 ++- backend/tests/test_trigger_config_updates.py | 151 +++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_trigger_config_updates.py diff --git a/backend/app/api/triggers.py b/backend/app/api/triggers.py index 02c7ff98d..215e48ec6 100644 --- a/backend/app/api/triggers.py +++ b/backend/app/api/triggers.py @@ -2,6 +2,7 @@ import uuid +from croniter import croniter from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select @@ -91,6 +92,20 @@ async def update_trigger( raise HTTPException(404, "Trigger not found") if body.config is not None: + if trigger.type == "cron": + expr = body.config.get("expr") + if not isinstance(expr, str) or not expr.strip(): + raise HTTPException( + 400, + "cron trigger requires config.expr", + ) + try: + croniter(expr) + except Exception as exc: + raise HTTPException( + 400, + f"Invalid cron expression: '{expr}'.", + ) from exc trigger.config = body.config if body.reason is not None: trigger.reason = body.reason diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 430579801..5fec8810e 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -10790,7 +10790,23 @@ async def _handle_update_trigger_outcome( for key, value in new_config.items() if key != "token" and not key.startswith("_") } - trigger.config = {**old_config, **user_patch, **protected} + updated_config = {**old_config, **user_patch, **protected} + if trigger.type == "cron": + expr = updated_config.get("expr") + if not isinstance(expr, str) or not expr.strip(): + return _typed_failure( + "cron trigger requires config.expr.", + "invalid_tool_arguments", + ) + try: + from croniter import croniter + croniter(expr) + except Exception: + return _typed_failure( + f"Invalid cron expression: '{expr}'.", + "invalid_tool_arguments", + ) + trigger.config = updated_config changes.append(f"config fields patched: {sorted(user_patch)}") if new_reason is not None: if not isinstance(new_reason, str) or not new_reason.strip(): diff --git a/backend/tests/test_trigger_config_updates.py b/backend/tests/test_trigger_config_updates.py new file mode 100644 index 000000000..73aed58ea --- /dev/null +++ b/backend/tests/test_trigger_config_updates.py @@ -0,0 +1,151 @@ +"""Validation at the existing Trigger update boundaries.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +import uuid + +from fastapi import HTTPException +import pytest + +from app.api import triggers as triggers_api +from app.models.trigger import AgentTrigger +from app.services import agent_tools, audit_logger + + +class _ScalarResult: + def __init__(self, value: AgentTrigger) -> None: + self._value = value + + def scalar_one_or_none(self) -> AgentTrigger: + return self._value + + +class _TriggerSession: + def __init__(self, trigger: AgentTrigger) -> None: + self._trigger = trigger + self.commit_count = 0 + + async def execute(self, _statement) -> _ScalarResult: + return _ScalarResult(self._trigger) + + async def commit(self) -> None: + self.commit_count += 1 + + +def _cron_trigger() -> AgentTrigger: + return AgentTrigger( + id=uuid.uuid4(), + agent_id=uuid.uuid4(), + name="daily-check", + type="cron", + config={"expr": "0 9 * * *"}, + reason="Daily check", + is_enabled=True, + fire_count=0, + cooldown_seconds=60, + ) + + +@pytest.mark.asyncio +async def test_rest_update_rejects_invalid_cron_before_commit(monkeypatch) -> None: + trigger = _cron_trigger() + session = _TriggerSession(trigger) + + @asynccontextmanager + async def fake_session(): + yield session + + monkeypatch.setattr(triggers_api, "async_session", fake_session) + + with pytest.raises(HTTPException) as error: + await triggers_api.update_trigger( + trigger.agent_id, + trigger.id, + triggers_api.TriggerUpdate(config={"expr": "not-a-cron"}), + user=object(), + ) + + assert error.value.status_code == 400 + assert trigger.config == {"expr": "0 9 * * *"} + assert session.commit_count == 0 + + +@pytest.mark.asyncio +async def test_rest_update_accepts_valid_cron(monkeypatch) -> None: + trigger = _cron_trigger() + session = _TriggerSession(trigger) + + @asynccontextmanager + async def fake_session(): + yield session + + monkeypatch.setattr(triggers_api, "async_session", fake_session) + + result = await triggers_api.update_trigger( + trigger.agent_id, + trigger.id, + triggers_api.TriggerUpdate(config={"expr": "30 9 * * 1-5"}), + user=object(), + ) + + assert result == {"ok": True} + assert trigger.config == {"expr": "30 9 * * 1-5"} + assert session.commit_count == 1 + + +@pytest.mark.asyncio +async def test_agent_tool_update_rejects_invalid_cron_before_commit( + monkeypatch, +) -> None: + trigger = _cron_trigger() + session = _TriggerSession(trigger) + + @asynccontextmanager + async def fake_session(): + yield session + + monkeypatch.setattr(agent_tools, "async_session", fake_session) + + outcome = await agent_tools._handle_update_trigger_outcome( + trigger.agent_id, + {"name": trigger.name, "config": {"expr": "not-a-cron"}}, + ) + + assert outcome.status == "failed" + assert outcome.error_code == "invalid_tool_arguments" + assert trigger.config == {"expr": "0 9 * * *"} + assert session.commit_count == 0 + + +@pytest.mark.asyncio +async def test_agent_tool_partial_update_keeps_valid_existing_cron( + monkeypatch, +) -> None: + trigger = _cron_trigger() + session = _TriggerSession(trigger) + + @asynccontextmanager + async def fake_session(): + yield session + + async def fake_audit_log(*_args, **_kwargs) -> None: + return None + + monkeypatch.setattr(agent_tools, "async_session", fake_session) + monkeypatch.setattr(audit_logger, "write_audit_log", fake_audit_log) + + outcome = await agent_tools._handle_update_trigger_outcome( + trigger.agent_id, + { + "name": trigger.name, + "config": {"timezone": "America/New_York"}, + }, + ) + + assert outcome.status == "succeeded" + assert trigger.config == { + "expr": "0 9 * * *", + "timezone": "America/New_York", + } + assert session.commit_count == 1 From bd98901b971618bf7bf4548cee93e18619aeaa56 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 19:15:03 +0800 Subject: [PATCH 06/53] Keep displayed Agent timezones aligned with scheduling The scheduler already falls back through Agent and Tenant to Asia/Shanghai, but the Agent detail response still advertised UTC when both stored values were absent. Reuse the platform default so the visible configuration describes the runtime behavior. Constraint: Preserve the existing Agent detail response shape Rejected: Add a new frontend timezone resolution path | the backend already owns effective timezone resolution Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any displayed effective timezone must follow the same Agent to Tenant to platform-default order as scheduling Tested: Full backend pytest 2163 passed; timezone tests 15 passed; scoped Ruff and git diff checks passed Not-tested: Frontend visual regression was unnecessary because the response field shape is unchanged --- backend/app/api/agents.py | 10 +++-- backend/tests/test_timezone_validation.py | 45 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index e7da1a80d..5278fcfb0 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -592,13 +592,17 @@ async def get_agent( creator = await user_dao.get_with_identity(agent.creator_id) out["creator_username"] = creator.username if creator else None - # Resolve effective timezone (agent → tenant → UTC) + # Resolve effective timezone (agent → tenant → platform default) effective_tz = agent.timezone if not effective_tz and agent.tenant_id: tenant = await tenant_dao.get(agent.tenant_id) if tenant: - effective_tz = tenant.timezone or "UTC" - out["effective_timezone"] = effective_tz or "UTC" + effective_tz = tenant.timezone + if not effective_tz: + from app.services.timezone_utils import DEFAULT_TIMEZONE + + effective_tz = DEFAULT_TIMEZONE + out["effective_timezone"] = effective_tz return out diff --git a/backend/tests/test_timezone_validation.py b/backend/tests/test_timezone_validation.py index a4a5922e2..951f9d493 100644 --- a/backend/tests/test_timezone_validation.py +++ b/backend/tests/test_timezone_validation.py @@ -2,9 +2,13 @@ from __future__ import annotations +import uuid +from types import SimpleNamespace + import pytest from pydantic import ValidationError +from app.api import agents as agents_api from app.api.tenants import TenantOut, TenantUpdate from app.models.tenant import Tenant from app.schemas.schemas import AgentUpdate @@ -45,3 +49,44 @@ def test_agent_update_accepts_inheritance_or_iana_timezone( def test_agent_update_rejects_invalid_timezone(timezone_name: str) -> None: with pytest.raises(ValidationError): AgentUpdate(timezone=timezone_name) + + +@pytest.mark.asyncio +async def test_agent_detail_uses_platform_timezone_when_agent_and_tenant_missing( + monkeypatch, +) -> None: + agent = SimpleNamespace( + id=uuid.uuid4(), + creator_id=None, + tenant_id=None, + timezone=None, + ) + + async def fake_check_agent_access(*_args, **_kwargs): + return agent, "manage" + + async def fake_lazy_reset(*_args, **_kwargs): + return False + + async def fake_agent_to_out(*_args, **_kwargs): + return SimpleNamespace(model_dump=lambda: {}) + + monkeypatch.setattr( + agents_api, + "check_agent_access", + fake_check_agent_access, + ) + monkeypatch.setattr( + agents_api, + "_lazy_reset_token_counters", + fake_lazy_reset, + ) + monkeypatch.setattr(agents_api, "_agent_to_out", fake_agent_to_out) + + result = await agents_api.get_agent( + agent.id, + current_user=SimpleNamespace(id=uuid.uuid4()), + db=SimpleNamespace(), + ) + + assert result["effective_timezone"] == "Asia/Shanghai" From ae3eb9c9a808c2a5d35b3f3f4c2ae49a77e791a8 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Wed, 5 Aug 2026 19:25:00 +0800 Subject: [PATCH 07/53] Keep the scheduling repair compatible with current main Current main routes Trigger persistence through query_dao and has advanced the Alembic chain. Preserve those boundaries while replaying the scheduling repair, move Cron imports to the module boundary, and adapt tests to the DAO session contract. Constraint: Publish without rebasing the user's dirty working tree Constraint: New migrations must follow the current single-head and DDL-only rules Rejected: Rebase the checked-out fix branch | unrelated concurrent work makes worktree mutation unsafe Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the PR branch based on current upstream main and retain query_dao session ownership Tested: Focused timezone and Trigger regression 33 passed; scoped Ruff passed; Alembic reports one f061 head Not-tested: Full backend suite was interrupted before completion --- ...=> v1_0_0_f061_default_tenant_timezone.py} | 25 ++++++++++++------- backend/app/api/agents.py | 3 +-- backend/app/api/tenants.py | 2 +- backend/app/services/agent_tools.py | 3 +-- .../app/services/trigger_runtime/evaluator.py | 3 ++- backend/tests/test_trigger_config_updates.py | 4 +-- .../tests/test_trigger_runtime_scheduling.py | 4 +-- 7 files changed, 25 insertions(+), 19 deletions(-) rename backend/alembic/versions/{202608051200_default_tenant_timezone.py => v1_0_0_f061_default_tenant_timezone.py} (53%) diff --git a/backend/alembic/versions/202608051200_default_tenant_timezone.py b/backend/alembic/versions/v1_0_0_f061_default_tenant_timezone.py similarity index 53% rename from backend/alembic/versions/202608051200_default_tenant_timezone.py rename to backend/alembic/versions/v1_0_0_f061_default_tenant_timezone.py index 96edf19da..f0119623b 100644 --- a/backend/alembic/versions/202608051200_default_tenant_timezone.py +++ b/backend/alembic/versions/v1_0_0_f061_default_tenant_timezone.py @@ -1,8 +1,19 @@ -"""Use Beijing as the required default tenant timezone. +"""F061: Use Beijing as the required default tenant timezone. -Revision ID: default_tenant_timezone -Revises: allow_checkpoint_deliveries +Revision ID: f061_default_tenant_timezone +Revises: f060_tenant_id_backfill Create Date: 2026-08-05 12:00:00 + +Background: + Agent scheduling inherits its timezone from the Tenant when the Agent has no + override, so new Tenants need a stable platform default. + +Scope: + Require the Tenant timezone column and change its server default to + Asia/Shanghai. + +Idempotent: + Reapplying the same nullability and server-default metadata is safe. """ from __future__ import annotations @@ -14,17 +25,13 @@ from alembic import op -revision: str = "default_tenant_timezone" -down_revision: str | None = "allow_checkpoint_deliveries" +revision: str = "f061_default_tenant_timezone" +down_revision: str | None = "f060_tenant_id_backfill" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: - op.execute( - "UPDATE tenants SET timezone = 'Asia/Shanghai' " - "WHERE timezone IS NULL OR btrim(timezone) = ''" - ) op.alter_column( "tenants", "timezone", diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 5278fcfb0..98cc1079f 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -24,6 +24,7 @@ from app.models.user import User from app.schemas.schemas import AgentCreate, AgentOut, AgentUpdate from app.services.storage import get_storage_backend +from app.services.timezone_utils import DEFAULT_TIMEZONE from app.services.access_relationships import ensure_access_granted_platform_relationships from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded from app.models.tenant import Tenant @@ -599,8 +600,6 @@ async def get_agent( if tenant: effective_tz = tenant.timezone if not effective_tz: - from app.services.timezone_utils import DEFAULT_TIMEZONE - effective_tz = DEFAULT_TIMEZONE out["effective_timezone"] = effective_tz diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index 024d7f46b..898ffe0e5 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -273,7 +273,7 @@ async def join_company( ic_result = await query_dao.execute(db, select(InvitationCode).where( InvitationCode.code == data.invitation_code, - InvitationCode.is_active == True, + InvitationCode.is_active.is_(True), InvitationCode.tenant_id.is_not(None), ) ) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 5fec8810e..38b2ef6c6 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -31,6 +31,7 @@ from typing import Optional, Any, cast import re +from croniter import croniter from loguru import logger from sqlalchemy import select, or_ @@ -10494,7 +10495,6 @@ async def _handle_set_trigger_outcome( "invalid_tool_arguments", ) try: - from croniter import croniter croniter(expr) except Exception: return _typed_failure( @@ -10799,7 +10799,6 @@ async def _handle_update_trigger_outcome( "invalid_tool_arguments", ) try: - from croniter import croniter croniter(expr) except Exception: return _typed_failure( diff --git a/backend/app/services/trigger_runtime/evaluator.py b/backend/app/services/trigger_runtime/evaluator.py index 3ad11795e..a4307fe1c 100644 --- a/backend/app/services/trigger_runtime/evaluator.py +++ b/backend/app/services/trigger_runtime/evaluator.py @@ -12,10 +12,11 @@ from sqlalchemy import select from app.dao import query_dao -async_session = query_dao.session from app.models.agent import Agent from app.models.trigger import AgentTrigger +async_session = query_dao.session + MIN_POLL_INTERVAL_MINUTES = 5 diff --git a/backend/tests/test_trigger_config_updates.py b/backend/tests/test_trigger_config_updates.py index 73aed58ea..06c9b7cb4 100644 --- a/backend/tests/test_trigger_config_updates.py +++ b/backend/tests/test_trigger_config_updates.py @@ -56,7 +56,7 @@ async def test_rest_update_rejects_invalid_cron_before_commit(monkeypatch) -> No async def fake_session(): yield session - monkeypatch.setattr(triggers_api, "async_session", fake_session) + monkeypatch.setattr(triggers_api.query_dao, "session", fake_session) with pytest.raises(HTTPException) as error: await triggers_api.update_trigger( @@ -80,7 +80,7 @@ async def test_rest_update_accepts_valid_cron(monkeypatch) -> None: async def fake_session(): yield session - monkeypatch.setattr(triggers_api, "async_session", fake_session) + monkeypatch.setattr(triggers_api.query_dao, "session", fake_session) result = await triggers_api.update_trigger( trigger.agent_id, diff --git a/backend/tests/test_trigger_runtime_scheduling.py b/backend/tests/test_trigger_runtime_scheduling.py index a64a8a759..7bbb24e92 100644 --- a/backend/tests/test_trigger_runtime_scheduling.py +++ b/backend/tests/test_trigger_runtime_scheduling.py @@ -194,7 +194,7 @@ async def test_dispatch_passes_occurrence_to_queue_unchanged() -> None: with ( patch( - "app.services.trigger_runtime.dispatch.async_session", + "app.services.trigger_runtime.dispatch.query_dao.session", return_value=_SessionContext(), ), patch( @@ -226,7 +226,7 @@ async def test_dispatch_logs_scheduled_occurrence_registration_failure() -> None with ( patch( - "app.services.trigger_runtime.dispatch.async_session", + "app.services.trigger_runtime.dispatch.query_dao.session", return_value=_SessionContext(), ), patch( From d5525db468f470d42b9fb4ce796c082ddca011e0 Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Thu, 6 Aug 2026 09:31:52 +0800 Subject: [PATCH 08/53] Honor user deletion of default Agents Persist tenant-scoped bootstrap identity so Morty and Meeseeks are initialized once, while surviving Agent storage remains repairable. Existing deployments lazily backfill stable IDs from the legacy marker or historical rows. Constraint: Existing deployments have no database bootstrap marker and docs are ignored by default. Rejected: Restore the legacy marker early return | it would disable storage drift repair and remains unsafe when storage changes. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not use Agent name or runtime status as the default-Agent initialization fact. Tested: 38 scoped pytest cases; Ruff; git diff --check. Not-tested: Real PostgreSQL container startup because the local Docker daemon is unavailable. --- backend/app/services/agent_seeder.py | 401 +++++++++++++----- .../tests/test_agent_seeder_storage_repair.py | 310 +++++++++++++- .../default-agent-seeding-technical-design.md | 280 ++++++++++++ 3 files changed, 860 insertions(+), 131 deletions(-) create mode 100644 docs/prd/features/agent-directory/default-agent-seeding-technical-design.md diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index 92d2d42d6..790dc07ff 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -4,7 +4,7 @@ from loguru import logger -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError @@ -13,6 +13,7 @@ from app.models.agent import Agent, AgentPermission from app.models.org import AgentAgentRelationship from app.models.skill import Skill +from app.models.tenant_setting import TenantSetting from app.models.tool import Tool, AgentTool from app.models.trigger import AgentTrigger from app.models.user import User @@ -23,6 +24,8 @@ settings = get_settings() SEED_MARKER_KEY = "_bootstrap/.seeded" +DEFAULT_AGENT_SEED_SETTING_KEY = "bootstrap:default_agents:v1" +DEFAULT_AGENT_NAMES = {"morty": "Morty", "meeseeks": "Meeseeks"} async def _read_seed_marker() -> str: @@ -42,6 +45,147 @@ async def _append_seed_marker(line: str) -> None: await storage.write_text(SEED_MARKER_KEY, updated, encoding="utf-8") +def _parse_default_agent_ids(value: object) -> dict[str, uuid.UUID | None]: + """Read stable default-Agent IDs from a tenant setting value.""" + raw_agents = value.get("agents") if isinstance(value, dict) else None + raw_agents = raw_agents if isinstance(raw_agents, dict) else {} + parsed: dict[str, uuid.UUID | None] = {} + for key in DEFAULT_AGENT_NAMES: + raw_id = raw_agents.get(key) + try: + parsed[key] = uuid.UUID(str(raw_id)) if raw_id else None + except (TypeError, ValueError, AttributeError): + parsed[key] = None + return parsed + + +def _parse_legacy_default_agent_ids(marker: str) -> dict[str, uuid.UUID | None]: + """Parse the last valid ID for each default Agent from the legacy marker.""" + parsed: dict[str, uuid.UUID | None] = {key: None for key in DEFAULT_AGENT_NAMES} + for line in marker.splitlines(): + key, separator, raw_id = line.partition("=") + if not separator or key not in DEFAULT_AGENT_NAMES: + continue + try: + parsed[key] = uuid.UUID(raw_id.strip()) + except ValueError: + continue + return parsed + + +def _default_agent_setting_value( + agent_ids: dict[str, uuid.UUID | None], + *, + source: str, +) -> dict: + return { + "initialized": True, + "agents": { + key: str(agent_ids.get(key)) if agent_ids.get(key) else None + for key in DEFAULT_AGENT_NAMES + }, + "source": source, + } + + +async def _lock_default_agent_seed(db: AsyncSession, tenant_id: uuid.UUID) -> None: + """Serialize first-seed and compatibility backfill for one tenant.""" + scope = f"default-agent-bootstrap:{tenant_id}" + await db.execute( + select(func.pg_advisory_xact_lock(func.hashtextextended(scope, 0))) + ) + + +async def _load_default_agents_by_ids( + db: AsyncSession, + tenant_id: uuid.UUID, + agent_ids: dict[str, uuid.UUID | None], +) -> dict[str, Agent | None]: + wanted_ids = {agent_id for agent_id in agent_ids.values() if agent_id is not None} + if not wanted_ids: + return {key: None for key in DEFAULT_AGENT_NAMES} + result = await db.execute( + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.id.in_(wanted_ids), + Agent.agent_type == "native", + ) + ) + agents_by_id = {agent.id: agent for agent in result.scalars().all()} + return { + key: agents_by_id.get(agent_id) if agent_id else None + for key, agent_id in agent_ids.items() + } + + +async def _load_historical_default_agents( + db: AsyncSession, + tenant_id: uuid.UUID, +) -> dict[str, Agent | None]: + """Find canonical-name history, including stopped and logically deleted rows.""" + result = await db.execute( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.name.in_(DEFAULT_AGENT_NAMES.values()), + Agent.agent_type == "native", + ) + .order_by(Agent.created_at.asc()) + ) + historical: dict[str, Agent | None] = {key: None for key in DEFAULT_AGENT_NAMES} + key_by_name = {name: key for key, name in DEFAULT_AGENT_NAMES.items()} + for agent in result.scalars().all(): + key = key_by_name.get(agent.name) + if key and historical[key] is None: + historical[key] = agent + return historical + + +async def _repair_seeded_default_agents( + db: AsyncSession, + agents: dict[str, Agent | None], + *, + created_keys: set[str] | None = None, +) -> None: + """Repair storage only for default Agents that still exist and are not deleted.""" + repairable = { + key: agent + for key, agent in agents.items() + if agent is not None and agent.deleted_at is None + } + if not repairable: + return + + all_skills_result = await db.execute( + select(Skill).options(selectinload(Skill.files)) + ) + all_skills = {skill.folder_name: skill for skill in all_skills_result.scalars().all()} + repair_specs = { + "morty": (MORTY_SOUL, MORTY_SKILLS), + "meeseeks": (MEESEEKS_SOUL, MEESEEKS_SKILLS), + } + for key, agent in repairable.items(): + soul_content, skill_folders = repair_specs[key] + await _repair_default_agent_storage( + db, + agent, + soul_content=soul_content, + skill_folders=skill_folders, + all_skills=all_skills, + overwrite_skill_files=key in (created_keys or set()), + ) + + +async def _append_default_agent_seed_marker( + agent_ids: dict[str, uuid.UUID | None], +) -> None: + """Preserve other bootstrap entries while recording default-Agent IDs.""" + await _append_seed_marker("seeded") + for key, agent_id in agent_ids.items(): + if agent_id: + await _append_seed_marker(f"{key}={agent_id}") + + async def _repair_default_agent_storage( db: AsyncSession, agent: Agent, @@ -262,14 +406,9 @@ async def _repair_default_agent_storage( async def seed_default_agents(): - """Create missing default agents and repair missing storage for existing ones. - - Database rows are the duplicate-creation guard. The storage marker is only - an operational hint because deployments can switch or lose storage while - preserving the database. - """ + """Initialize default Agents once, then only repair surviving Agent storage.""" + marker_ids_to_write: dict[str, uuid.UUID | None] | None = None async with async_session() as db: - # Get platform admin as creator admin_result = await db.execute( select(User).where(User.role == "platform_admin").limit(1) @@ -279,27 +418,78 @@ async def seed_default_agents(): logger.warning("[AgentSeeder] No platform admin found, skipping default agents") return - # DB-backed idempotency is the source of truth. The storage marker can - # disappear when deployments switch volumes/backends, so it is only a - # fast-path hint and must never be the only duplicate guard. - existing_result = await db.execute( - select(Agent) - .where( - Agent.tenant_id == admin.tenant_id, - Agent.name.in_(["Morty", "Meeseeks"]), - Agent.agent_type == "native", - Agent.status != "stopped", + await _lock_default_agent_seed(db, admin.tenant_id) + + setting_result = await db.execute( + select(TenantSetting).where( + TenantSetting.tenant_id == admin.tenant_id, + TenantSetting.key == DEFAULT_AGENT_SEED_SETTING_KEY, ) - .order_by(Agent.created_at.asc()) ) - existing_by_name: dict[str, Agent] = {} - for agent in existing_result.scalars().all(): - existing_by_name.setdefault(agent.name, agent) + seed_setting = setting_result.scalar_one_or_none() + + if seed_setting is not None: + seed_value = seed_setting.value if isinstance(seed_setting.value, dict) else {} + if seed_value.get("initialized") is not True: + logger.warning( + "[AgentSeeder] Default-Agent initialization setting is malformed; " + "skipping creation conservatively" + ) + agent_ids = _parse_default_agent_ids(seed_setting.value) + seeded_agents = await _load_default_agents_by_ids( + db, + admin.tenant_id, + agent_ids, + ) + await _repair_seeded_default_agents(db, seeded_agents) + await db.commit() + logger.info( + "[AgentSeeder] Default Agents already initialized; " + "creation skipped and surviving storage checked" + ) + return - created_agents: list[Agent] = [] - created_names: set[str] = set() + # Existing deployments predate the DB setting. Recover stable IDs from + # the shared legacy marker first, then fall back to canonical-name DB + # history including stopped and logically deleted rows. + try: + legacy_ids = _parse_legacy_default_agent_ids(await _read_seed_marker()) + except Exception as exc: + logger.warning(f"[AgentSeeder] Legacy seed marker unavailable: {exc}") + legacy_ids = {key: None for key in DEFAULT_AGENT_NAMES} - if "Morty" not in existing_by_name: + seeded_agents = await _load_default_agents_by_ids( + db, + admin.tenant_id, + legacy_ids, + ) + if any(agent is not None for agent in seeded_agents.values()): + source = "legacy_marker" + else: + seeded_agents = await _load_historical_default_agents(db, admin.tenant_id) + source = "database_history" + + if any(agent is not None for agent in seeded_agents.values()): + agent_ids = { + key: agent.id if agent is not None else None + for key, agent in seeded_agents.items() + } + db.add( + TenantSetting( + tenant_id=admin.tenant_id, + key=DEFAULT_AGENT_SEED_SETTING_KEY, + value=_default_agent_setting_value(agent_ids, source=source), + ) + ) + await _repair_seeded_default_agents(db, seeded_agents) + await db.commit() + marker_ids_to_write = agent_ids + logger.info( + "[AgentSeeder] Backfilled default-Agent initialization state: " + f"tenant={admin.tenant_id} source={source}" + ) + else: + # No durable initialization evidence: this is a fresh tenant. morty = Agent( name="Morty", role_description="Research analyst & knowledge assistant — curious, thorough, great at finding and synthesizing information", @@ -309,13 +499,6 @@ async def seed_default_agents(): tenant_id=admin.tenant_id, status="idle", ) - db.add(morty) - created_agents.append(morty) - created_names.add("Morty") - else: - morty = existing_by_name["Morty"] - - if "Meeseeks" not in existing_by_name: meeseeks = Agent( name="Meeseeks", role_description="Task executor & project manager — goal-oriented, systematic planner, strong at breaking down and completing complex tasks", @@ -325,100 +508,86 @@ async def seed_default_agents(): tenant_id=admin.tenant_id, status="idle", ) + db.add(morty) db.add(meeseeks) - created_agents.append(meeseeks) - created_names.add("Meeseeks") - else: - meeseeks = existing_by_name["Meeseeks"] - - await db.flush() # get IDs - - # ── Participant identities ── - from app.models.participant import Participant - for agent in created_agents: - db.add(Participant(type="agent", ref_id=agent.id, display_name=agent.name, avatar_url=agent.avatar_url)) - await db.flush() - - # ── Permissions (company-wide, manage) ── - for agent in created_agents: - db.add(AgentPermission(agent_id=agent.id, scope_type="company", access_level="manage")) - - # ── Assign skills ── - all_skills_result = await db.execute( - select(Skill).options(selectinload(Skill.files)) - ) - all_skills = {s.folder_name: s for s in all_skills_result.scalars().all()} - - await _repair_default_agent_storage( - db, - morty, - soul_content=MORTY_SOUL, - skill_folders=MORTY_SKILLS, - all_skills=all_skills, - overwrite_skill_files=morty.name in created_names, - ) - await _repair_default_agent_storage( - db, - meeseeks, - soul_content=MEESEEKS_SOUL, - skill_folders=MEESEEKS_SKILLS, - all_skills=all_skills, - overwrite_skill_files=meeseeks.name in created_names, - ) + await db.flush() - # ── Assign all default tools ── - default_tools_result = await db.execute( - select(Tool).where(Tool.is_default) - ) - default_tools = default_tools_result.scalars().all() + created_agents = {"morty": morty, "meeseeks": meeseeks} + agent_ids = {key: agent.id for key, agent in created_agents.items()} + db.add( + TenantSetting( + tenant_id=admin.tenant_id, + key=DEFAULT_AGENT_SEED_SETTING_KEY, + value=_default_agent_setting_value(agent_ids, source="created"), + ) + ) - for agent in created_agents: - for tool in default_tools: - db.add(AgentTool(agent_id=agent.id, tool_id=tool.id, enabled=True)) + from app.models.participant import Participant - # ── Mutual relationships ── - relationship_specs = [ - ( - morty.id, - meeseeks.id, - "Expert task executor who breaks down complex tasks into structured plans and executes them systematically. Delegate multi-step tasks to him.", - ), - ( - meeseeks.id, - morty.id, - "Research expert with strong learning ability. Ask him for information retrieval, web research, data analysis, and knowledge synthesis.", - ), - ] - for agent_id, target_agent_id, description in relationship_specs: - rel_result = await db.execute( - select(AgentAgentRelationship).where( - AgentAgentRelationship.agent_id == agent_id, - AgentAgentRelationship.target_agent_id == target_agent_id, + for agent in created_agents.values(): + db.add( + Participant( + type="agent", + ref_id=agent.id, + display_name=agent.name, + avatar_url=agent.avatar_url, + ) ) + db.add( + AgentPermission( + agent_id=agent.id, + scope_type="company", + access_level="manage", + ) + ) + await db.flush() + + await _repair_seeded_default_agents( + db, + created_agents, + created_keys=set(created_agents), ) - if not rel_result.scalar_one_or_none(): - db.add(AgentAgentRelationship( - agent_id=agent_id, - target_agent_id=target_agent_id, - relation="collaborator", - description=description, - )) + default_tools_result = await db.execute(select(Tool).where(Tool.is_default)) + default_tools = default_tools_result.scalars().all() + for agent in created_agents.values(): + for tool in default_tools: + db.add(AgentTool(agent_id=agent.id, tool_id=tool.id, enabled=True)) + relationship_specs = [ + ( + morty.id, + meeseeks.id, + "Expert task executor who breaks down complex tasks into structured plans and executes them systematically. Delegate multi-step tasks to him.", + ), + ( + meeseeks.id, + morty.id, + "Research expert with strong learning ability. Ask him for information retrieval, web research, data analysis, and knowledge synthesis.", + ), + ] + for agent_id, target_agent_id, description in relationship_specs: + db.add( + AgentAgentRelationship( + agent_id=agent_id, + target_agent_id=target_agent_id, + relation="collaborator", + description=description, + ) + ) - await db.commit() - logger.info( - "[AgentSeeder] Default agent seeding complete: " - f"Morty ({morty.id}), Meeseeks ({meeseeks.id}), created={len(created_agents)}" - ) + await db.commit() + marker_ids_to_write = agent_ids + logger.info( + "[AgentSeeder] Default Agent initialization complete: " + f"Morty ({morty.id}), Meeseeks ({meeseeks.id})" + ) - # Write seed marker AFTER a successful commit so a failed seed can be retried - await get_storage_backend().write_text( - SEED_MARKER_KEY, - f"seeded\nmorty={morty.id}\nmeeseeks={meeseeks.id}\n", - encoding="utf-8", - ) - logger.info(f"[AgentSeeder] Wrote seed marker to {SEED_MARKER_KEY}") + if marker_ids_to_write: + try: + await _append_default_agent_seed_marker(marker_ids_to_write) + except Exception as exc: + logger.warning(f"[AgentSeeder] Failed to update legacy seed marker: {exc}") async def seed_okr_agent(): diff --git a/backend/tests/test_agent_seeder_storage_repair.py b/backend/tests/test_agent_seeder_storage_repair.py index 52f9a6aa7..94c791b79 100644 --- a/backend/tests/test_agent_seeder_storage_repair.py +++ b/backend/tests/test_agent_seeder_storage_repair.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock import uuid @@ -30,8 +31,18 @@ async def __aexit__(self, exc_type, exc, traceback): return False -def _agent(name: str = "Morty") -> SimpleNamespace: - return SimpleNamespace(id=uuid.uuid4(), name=name) +def _agent( + name: str = "Morty", + *, + status: str = "idle", + deleted_at=None, +) -> SimpleNamespace: + return SimpleNamespace( + id=uuid.uuid4(), + name=name, + status=status, + deleted_at=deleted_at, + ) def _skill(folder_name: str = "skill-creator", *, is_default: bool = True) -> SimpleNamespace: @@ -141,23 +152,30 @@ async def test_seed_existing_default_agents_still_runs_storage_repair(monkeypatc admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) morty = _agent("Morty") meeseeks = _agent("Meeseeks") + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=None) + if "FROM agents" in sql and "agents.id IN" in sql: + return _Result(scalars=[]) + if "FROM agents" in sql: + return _Result(scalars=[morty, meeseeks]) + return _Result(scalars=[]) + session = SimpleNamespace( - execute=AsyncMock( - side_effect=[ - _Result(scalar=admin), - _Result(scalars=[morty, meeseeks]), - _Result(scalars=[]), - _Result(scalars=[]), - _Result(scalar=None), - _Result(scalar=None), - ] - ), + execute=AsyncMock(side_effect=execute), flush=AsyncMock(), commit=AsyncMock(), - add=lambda value: None, + add=added.append, ) repair = AsyncMock(return_value=False) - storage = SimpleNamespace(write_text=AsyncMock()) + storage = _empty_storage() monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) @@ -166,5 +184,267 @@ async def test_seed_existing_default_agents_still_runs_storage_repair(monkeypatc assert repair.await_count == 2 assert [call.args[1].name for call in repair.await_args_list] == ["Morty", "Meeseeks"] + assert any(value.__class__.__name__ == "TenantSetting" for value in added) + session.commit.assert_awaited_once() + assert storage.write_text.await_count >= 1 + + +def _empty_storage(*, marker: str = "") -> SimpleNamespace: + return SimpleNamespace( + exists=AsyncMock(return_value=bool(marker)), + read_text=AsyncMock(return_value=marker), + write_text=AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test_append_default_agent_marker_preserves_other_seed_entries(monkeypatch): + content = "seeded\nokr_agent=existing\n" + + async def read_text(*_args, **_kwargs): + return storage.content + + async def write_text(_key, value, **_kwargs): + storage.content = value + + storage = SimpleNamespace( + content=content, + exists=AsyncMock(return_value=True), + read_text=AsyncMock(side_effect=read_text), + write_text=AsyncMock(side_effect=write_text), + ) + monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) + morty_id = uuid.uuid4() + meeseeks_id = uuid.uuid4() + + await agent_seeder._append_default_agent_seed_marker( + {"morty": morty_id, "meeseeks": meeseeks_id} + ) + + assert "okr_agent=existing\n" in storage.content + assert f"morty={morty_id}\n" in storage.content + assert f"meeseeks={meeseeks_id}\n" in storage.content + + +@pytest.mark.asyncio +async def test_seed_deleted_default_agents_backfills_without_recreating(monkeypatch): + admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) + deleted_at = datetime.now(timezone.utc) + deleted_agents = [ + _agent("Morty", status="stopped", deleted_at=deleted_at), + _agent("Meeseeks", status="stopped", deleted_at=deleted_at), + ] + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=None) + if "FROM agents" in sql: + if "agents.status !=" in sql: + return _Result(scalars=[]) + return _Result(scalars=deleted_agents) + return _Result(scalars=[]) + + session = SimpleNamespace( + execute=AsyncMock(side_effect=execute), + flush=AsyncMock(), + commit=AsyncMock(), + add=added.append, + ) + repair = AsyncMock(return_value=False) + storage = _empty_storage() + monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) + monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) + monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) + + await agent_seeder.seed_default_agents() + + assert not any(isinstance(value, agent_seeder.Agent) for value in added) + assert any(value.__class__.__name__ == "TenantSetting" for value in added) + repair.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_seed_legacy_marker_backfills_renamed_agents_without_recreating(monkeypatch): + admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) + renamed_morty = _agent("Researcher") + renamed_meeseeks = _agent("Executor") + marker = ( + "seeded\n" + f"morty={renamed_morty.id}\n" + f"meeseeks={renamed_meeseeks.id}\n" + ) + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=None) + if "FROM agents" in sql and "agents.id IN" in sql: + return _Result(scalars=[renamed_morty, renamed_meeseeks]) + if "FROM agents" in sql: + return _Result(scalars=[]) + return _Result(scalars=[]) + + session = SimpleNamespace( + execute=AsyncMock(side_effect=execute), + flush=AsyncMock(), + commit=AsyncMock(), + add=added.append, + ) + repair = AsyncMock(return_value=False) + storage = _empty_storage(marker=marker) + monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) + monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) + monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) + + await agent_seeder.seed_default_agents() + + assert not any(isinstance(value, agent_seeder.Agent) for value in added) + assert any(value.__class__.__name__ == "TenantSetting" for value in added) + assert [call.args[1].id for call in repair.await_args_list] == [ + renamed_morty.id, + renamed_meeseeks.id, + ] + + +@pytest.mark.asyncio +async def test_seed_database_marker_skips_deleted_and_repairs_stopped_survivor(monkeypatch): + admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) + deleted_morty = _agent( + "Morty", + status="stopped", + deleted_at=datetime.now(timezone.utc), + ) + stopped_meeseeks = _agent("Meeseeks", status="stopped") + setting = SimpleNamespace( + value={ + "initialized": True, + "agents": { + "morty": str(deleted_morty.id), + "meeseeks": str(stopped_meeseeks.id), + }, + "source": "created", + } + ) + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=setting) + if "FROM agents" in sql: + return _Result(scalars=[deleted_morty, stopped_meeseeks]) + return _Result(scalars=[]) + + session = SimpleNamespace( + execute=AsyncMock(side_effect=execute), + flush=AsyncMock(), + commit=AsyncMock(), + add=added.append, + ) + repair = AsyncMock(return_value=False) + storage = _empty_storage() + monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) + monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) + monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) + + await agent_seeder.seed_default_agents() + + assert not any(isinstance(value, agent_seeder.Agent) for value in added) + repair.assert_awaited_once() + assert repair.await_args.args[1].id == stopped_meeseeks.id + storage.write_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_seed_malformed_database_marker_never_recreates(monkeypatch): + admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) + setting = SimpleNamespace(value={"unexpected": "value"}) + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=setting) + return _Result(scalars=[]) + + session = SimpleNamespace( + execute=AsyncMock(side_effect=execute), + flush=AsyncMock(), + commit=AsyncMock(), + add=added.append, + ) + repair = AsyncMock(return_value=False) + monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) + monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) + + await agent_seeder.seed_default_agents() + + assert not any(isinstance(value, agent_seeder.Agent) for value in added) + repair.assert_not_awaited() + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_seed_fresh_tenant_creates_agents_and_database_marker(monkeypatch): + admin = SimpleNamespace(id=uuid.uuid4(), tenant_id=uuid.uuid4()) + added = [] + + async def execute(statement): + sql = str(statement) + if "FROM users" in sql: + return _Result(scalar=admin) + if "pg_advisory_xact_lock" in sql: + return _Result() + if "FROM tenant_settings" in sql: + return _Result(scalar=None) + return _Result(scalars=[]) + + async def flush(): + for value in added: + if isinstance(value, agent_seeder.Agent) and value.id is None: + value.id = uuid.uuid4() + + session = SimpleNamespace( + execute=AsyncMock(side_effect=execute), + flush=AsyncMock(side_effect=flush), + commit=AsyncMock(), + add=added.append, + ) + repair = AsyncMock(return_value=False) + storage = _empty_storage() + monkeypatch.setattr(agent_seeder, "async_session", lambda: _SessionContext(session)) + monkeypatch.setattr(agent_seeder, "_repair_default_agent_storage", repair) + monkeypatch.setattr(agent_seeder, "get_storage_backend", lambda: storage) + + await agent_seeder.seed_default_agents() + + created_agents = [value for value in added if isinstance(value, agent_seeder.Agent)] + settings = [value for value in added if value.__class__.__name__ == "TenantSetting"] + assert [agent.name for agent in created_agents] == ["Morty", "Meeseeks"] + assert len(settings) == 1 + assert settings[0].value["initialized"] is True + assert settings[0].value["source"] == "created" + assert all(settings[0].value["agents"].values()) + assert repair.await_count == 2 session.commit.assert_awaited_once() - storage.write_text.assert_awaited_once() + executed_sql = "\n".join(str(call.args[0]) for call in session.execute.await_args_list) + assert "pg_advisory_xact_lock" in executed_sql diff --git a/docs/prd/features/agent-directory/default-agent-seeding-technical-design.md b/docs/prd/features/agent-directory/default-agent-seeding-technical-design.md new file mode 100644 index 000000000..82da0b852 --- /dev/null +++ b/docs/prd/features/agent-directory/default-agent-seeding-technical-design.md @@ -0,0 +1,280 @@ +# 默认 Agent 一次性初始化技术方案 + +> 状态:待实现 +> +> 范围:Morty、Meeseeks 的首次创建、升级兼容和存储自愈 + +## 1. 业务语义 + +Morty 和 Meeseeks 是租户首次完成平台初始化时创建的默认 Agent。 + +初始化成功后,平台必须尊重用户对这两个 Agent 的生命周期操作: + +- 用户删除后,后续启动、重启和升级不得重新创建。 +- 用户重命名后,不得因为默认名称消失而创建同名副本。 +- 用户仅停止 Agent 时,不得创建副本。 +- 未删除的默认 Agent 如果 workspace 或 Skills 存储损坏,启动时仍可执行非覆盖式修复。 + +因此,“是否创建默认 Agent”与“是否修复默认 Agent 存储”必须是两项独立判断。 + +## 2. 当前实现与问题 + +### 2.1 当前调用链 + +`seed_default_agents()` 在两个入口运行: + +- 后端启动流程:`backend/app/main.py` +- 首个平台注册用户创建完成后:`backend/app/api/auth.py` + +重复调用本身是允许的,前提是 seeder 具备可靠的一次性语义。 + +### 2.2 当前创建判据 + +当前 seeder 按以下条件查找已有默认 Agent: + +```python +Agent.tenant_id == admin.tenant_id +Agent.name.in_(["Morty", "Meeseeks"]) +Agent.agent_type == "native" +Agent.status != "stopped" +``` + +如果对应名称不在查询结果中,就创建新的 Agent。 + +这个判据把可变运行状态当成了初始化事实: + +- 删除接口会保留 Agent 行,同时设置 `deleted_at` 和 `status="stopped"`。 +- 停止接口也会设置 `status="stopped"`。 +- 重命名会改变 `name`。 + +因此删除、停止和重命名都可能被错误解释为“从未初始化”。 + +### 2.3 现有 seed marker + +存储中已有 `_bootstrap/.seeded`,但默认 Agent seeder 当前只写入、不读取该标记。该文件不能作为新的唯一事实源:部署可能更换或丢失存储,而数据库仍然保留。 + +### 2.4 必须保留的存储自愈 + +现有 `_repair_default_agent_storage()` 会为仍存在的默认 Agent 修复缺失的根目录和 Skills 目录,并避免覆盖用户文件。这个能力必须保留,不能恢复成“发现 seed marker 后整段 seeder 直接返回”。 + +## 3. 技术目标 + +1. 使用租户级、持久、与名称和运行状态无关的初始化事实。 +2. 默认 Agent 每个租户最多自动初始化一次。 +3. 删除、停止、重命名均不触发重新创建。 +4. 对未删除的默认 Agent 保留存储自愈。 +5. 兼容没有数据库初始化标记的现有部署。 +6. 多实例同时启动时不得重复创建。 +7. 不增加依赖,优先复用现有表和数据库锁模式。 + +## 4. 数据事实源 + +### 4.1 新的规范事实 + +复用现有 `tenant_settings` 表,不新增表和 Alembic migration。 + +建议设置项: + +```text +key = "bootstrap:default_agents:v1" +``` + +建议 value: + +```json +{ + "initialized": true, + "agents": { + "morty": "", + "meeseeks": "" + }, + "source": "created|legacy_marker|database_history" +} +``` + +语义: + +- 设置项存在即表示该租户已经完成过默认 Agent 初始化;`initialized=true` 用于校验和诊断。即使 value 损坏,也必须保守地停止自动创建并记录告警。 +- Agent ID 是稳定身份,用于后续存储修复;不再通过名称反查身份。 +- ID 对应 Agent 已删除或物理不存在时,也不得重新创建。 +- `source` 仅用于诊断和升级审计,不参与业务判断。 + +### 4.2 删除事实 + +`Agent.deleted_at` 是 Agent 是否被用户逻辑删除的事实源。 + +- `deleted_at is None`:Agent 仍存在,可以检查和修复存储。 +- `deleted_at is not None`:Agent 已删除,跳过存储修复,也不得补建。 +- `status` 只描述运行状态,不参与初始化或删除判断。 + +### 4.3 legacy marker 的角色 + +`_bootstrap/.seeded` 只用于现有部署的兼容识别和运维诊断,不再作为长期唯一事实源。 + +后续如仍需写入 legacy marker,必须使用追加/合并方式,不能覆盖 `okr_agent` 等其他 seed 信息。 + +## 5. 核心流程 + +### 5.1 并发边界 + +进入租户默认 Agent 初始化流程后,先获取租户级 PostgreSQL transaction advisory lock。锁键建议包含租户 ID和固定命名空间: + +```text +default-agent-bootstrap: +``` + +锁内重新读取 `tenant_settings`,避免多个后端实例同时判断“未初始化”并重复创建。 + +### 5.2 已有数据库标记 + +如果 `bootstrap:default_agents:v1` 已存在: + +1. 不执行任何默认 Agent 创建。 +2. 按设置中保存的 Agent ID 查询数据库,查询必须包含 stopped 和逻辑删除行。 +3. 对 `deleted_at is None` 的 Agent 调用 `_repair_default_agent_storage()`。 +4. 对已删除或不存在的 Agent 直接跳过。 + +### 5.3 新租户首次初始化 + +如果数据库标记不存在,并且兼容识别没有发现历史初始化事实: + +1. 创建 Morty 和 Meeseeks。 +2. 创建 Participant、权限、默认工具和相互关系。 +3. 初始化 workspace 和 Skills。 +4. 在同一数据库事务中写入 `bootstrap:default_agents:v1`,保存两个 Agent ID。 +5. 提交事务。 +6. 数据库提交成功后,以追加方式更新 legacy marker;marker 写入失败只记录告警,不回滚已经成立的数据库事实。 + +数据库中的 Agent 和初始化设置必须一起提交,避免出现“Agent 已创建但初始化设置缺失”的中间状态。 + +## 6. 现有部署兼容 + +### 6.1 是否必须回填 + +如果采用 `tenant_settings` 作为新的规范事实,现有租户必须建立这个事实,否则“数据库标记不存在”仍可能被错误理解为全新租户。 + +但不需要: + +- 新增 Alembic 数据迁移; +- 单独执行离线回填脚本; +- 人工逐租户处理。 + +采用 seeder 首次运行时的懒回填即可。也就是说,兼容回填是逻辑上必须的,但不需要独立发布步骤。 + +### 6.2 懒回填顺序 + +数据库标记不存在时,按以下顺序识别历史初始化: + +1. 读取 legacy marker 中的 `morty`、`meeseeks` ID。 +2. 校验 marker 指向的 Agent 是否属于当前租户;查询包含已删除和 stopped 行。 +3. 如果 marker 无法使用,则查询当前租户所有历史 Agent 行,包括已删除和 stopped 行,查找曾存在的 canonical 名称 Morty/Meeseeks。 +4. 发现任一可信历史证据,就写入 `bootstrap:default_agents:v1`,`source` 分别记录为 `legacy_marker` 或 `database_history`,不创建缺失 Agent。 +5. 只有完全没有数据库标记、有效 legacy marker 和历史 Agent 证据时,才执行首次创建。 + +这里采用保守策略:有历史证据时宁可不自动创建,也不能覆盖用户删除意图。 + +### 6.3 无法完全恢复的历史状态 + +如果现有部署同时满足以下条件: + +- legacy marker 已丢失; +- 默认 Agent 已被重命名; +- 数据库中没有可识别的 canonical 名称历史; +- 数据库初始化标记尚未建立; + +系统无法只根据现有数据可靠证明该 Agent 曾由默认 seeder 创建。不得通过角色描述、Bio 或 workspace 内容做模糊猜测。 + +该极端状态只能通过运维确认后补写租户设置。修复上线后,新数据库标记会消除后续同类歧义。 + +### 6.4 已被旧逻辑重新创建的 Agent + +升级兼容过程不自动删除当前活跃 Agent。系统无法可靠判断用户是否已经开始使用旧逻辑重新创建出的对象。 + +用户可在修复上线后再次删除该 Agent;数据库初始化事实已经建立,后续不会再次创建。 + +## 7. 代码改动范围 + +### 7.1 `backend/app/services/agent_seeder.py` + +- 引入 `TenantSetting`。 +- 增加默认 Agent 设置 key 和 value 解析函数。 +- 增加 legacy marker 解析和懒回填函数。 +- 增加租户级 transaction advisory lock。 +- 将 `seed_default_agents()` 拆为: + - 初始化事实解析; + - 首次创建; + - 现存 Agent 存储修复。 +- 移除以 `name + status != stopped` 作为创建判据的逻辑。 +- 保留 `_repair_default_agent_storage()` 的非覆盖语义。 +- legacy marker 改为追加/合并写入,避免覆盖其他 seeder 条目。 + +### 7.2 `backend/tests/test_agent_seeder_storage_repair.py` + +扩展现有测试覆盖初始化状态、兼容回填和删除语义。 + +不需要修改前端、Agent 删除接口或数据库结构。 + +## 8. 测试设计 + +### 8.1 首次创建 + +- 没有设置、marker 和历史 Agent 时创建两个默认 Agent。 +- 创建与租户初始化设置在同一事务提交。 +- 初始化失败时不留下 `initialized=true`。 + +### 8.2 已初始化 + +- 两个 Agent 都存在:不创建,继续执行存储健康检查。 +- Morty 已删除:不创建 Morty,只检查未删除的 Meeseeks。 +- 两个都已删除:不创建,也不修复存储。 +- Agent 仅 stopped、未删除:不创建副本,仍允许存储修复。 +- Agent 已重命名:按 ID 识别,不创建 canonical 名称副本。 +- 设置中的 Agent ID 已不存在:不创建。 + +### 8.3 兼容回填 + +- legacy marker 有效:写入租户设置,不创建。 +- marker 指向已删除 Agent:仍视为已初始化,不创建。 +- marker 缺失但数据库存在历史 canonical Agent:写入租户设置,不创建。 +- marker 来自其他租户或格式损坏:忽略 marker,继续数据库历史判断。 +- 完全没有历史证据:执行首次创建。 + +### 8.4 并发 + +- 两个 seeder 并发进入时,只有锁内第一个流程可以创建。 +- 第二个流程取得锁后重新读取设置并进入已初始化分支。 + +### 8.5 回归验证 + +- 运行 `backend/tests/test_agent_seeder_storage_repair.py`。 +- 运行与 Agent 删除、列表可见性相关的 scoped tests。 +- 对修改文件运行 Ruff。 +- 验证现有存储漂移修复测试继续通过。 + +## 9. 验收标准 + +- 新租户仍自动获得 Morty 和 Meeseeks。 +- 删除任一默认 Agent 后,连续重启两次均不出现新副本。 +- 重命名任一默认 Agent 后,连续重启两次均不出现 canonical 名称副本。 +- stop 后重启不产生副本。 +- 未删除默认 Agent 的 workspace/Skills 丢失后仍能被修复。 +- 现有部署无需人工脚本即可自动建立数据库初始化事实。 +- 多实例同时启动不会重复创建默认 Agent。 + +## 10. 非目标与风险 + +- 本次不自动清理旧版本已经创建的重复 Agent。 +- 本次不改变普通 Agent 的删除、停止或重命名接口。 +- 本次不把 Morty/Meeseeks 改成不可删除的 system Agent。 +- 本次不以名称、Bio、角色描述等可变内容作为长期身份。 +- legacy marker 丢失且历史 Agent 已重命名的极端部署,需要运维确认;不做推测性自动修复。 + +## 11. 实施顺序 + +1. 先补删除、停止、重命名和 legacy 回填的失败测试。 +2. 增加租户初始化设置和兼容解析函数。 +3. 加入租户级并发锁。 +4. 拆分首次创建与存储修复路径。 +5. 运行 scoped tests 和 Ruff。 +6. 使用本地数据库验证首次初始化与删除后重启。 +7. 部署前检查目标环境当前 marker、历史默认 Agent 行和重复 Agent 状态,不自动清理数据。 From 328eaffadee34ef8593f15c2c049c4161d8888da Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Mon, 10 Aug 2026 17:22:14 +0800 Subject: [PATCH 09/53] Keep every group mention candidate reachable The mention picker stopped at eight results and keyboard navigation could move the active candidate outside the visible popup. Keep all filtered members in the bounded list and synchronize its local scroll position with the highlighted option. Constraint: Preserve existing structured mention identity and IME behavior. Rejected: Keep the eight-candidate cap | members beyond the cap would remain unreachable by scrolling. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep keyboard highlight changes synchronized with the mention popup scroll position. Tested: Frontend npm test (89 passed); npm run build; Playwright with 12 mocked members, mouse wheel and keyboard scrolling. Not-tested: Live backend WebSocket behavior; unrelated to the local candidate picker. --- frontend/src/pages/groups/MessageComposer.tsx | 30 +++++++++++++++---- frontend/src/pages/groups/groups.css | 1 + .../tests/groupInteractionContract.test.mjs | 17 +++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/groups/MessageComposer.tsx b/frontend/src/pages/groups/MessageComposer.tsx index 0ca9fd481..75e6a50c9 100644 --- a/frontend/src/pages/groups/MessageComposer.tsx +++ b/frontend/src/pages/groups/MessageComposer.tsx @@ -47,6 +47,8 @@ export default function MessageComposer({ }: MessageComposerProps) { const { t } = useTranslation(); const textareaRef = useRef(null); + const mentionPopupRef = useRef(null); + const mentionOptionRefs = useRef>([]); const [value, setValue] = useState(''); const [query, setQuery] = useState(null); const [highlighted, setHighlighted] = useState(0); @@ -63,12 +65,27 @@ export default function MessageComposer({ const candidates = useMemo(() => { if (!query) return []; const needle = query.text.toLowerCase(); - return members - .filter((member) => member.display_name.toLowerCase().includes(needle)) - .slice(0, 8); + return members.filter((member) => member.display_name.toLowerCase().includes(needle)); }, [members, query]); - useEffect(() => setHighlighted(0), [query?.text]); + useEffect(() => { + setHighlighted(0); + if (mentionPopupRef.current) mentionPopupRef.current.scrollTop = 0; + }, [query?.text]); + + useEffect(() => { + const popup = mentionPopupRef.current; + const option = mentionOptionRefs.current[highlighted]; + if (!popup || !option) return; + + const popupRect = popup.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + if (optionRect.top < popupRect.top) { + popup.scrollTop -= popupRect.top - optionRect.top; + } else if (optionRect.bottom > popupRect.bottom) { + popup.scrollTop += optionRect.bottom - popupRect.bottom; + } + }, [candidates, highlighted]); // Auto-grow the textarea to fit its content (capped by max-height in CSS). Runs for typing, // mention insertion and the post-send clear alike, since they all flow through `value`. @@ -177,9 +194,12 @@ export default function MessageComposer({ return (
{query && candidates.length > 0 && ( -
+
{candidates.map((member, index) => (