feat: initial AutoPilot monorepo

- master/  : Spring Boot cloud coordinator (Gap 1-4 features)
- worker/  : Selenium execution engine
- ui/      : React + Vite dashboard (CI/CD integration page)
- docker-compose.yml       : production deployment
- docker-compose.local.yml : local dev (DB only)
- .env.example : environment variable template
- README.md : full deployment and local setup guide
This commit is contained in:
vithobaa 2026-06-29 18:20:23 +05:30
commit a467069c9c
584 changed files with 33875 additions and 0 deletions

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# AutoPilot - Production Environment Variables
# Copy this file to .env and fill in the real values before deploying
# ─── Database (PostgreSQL) ───────────────────────────────────────────────────
DB_URL=jdbc:postgresql://postgres:5432/localagent_cloud
DB_USER=localagent
DB_PASS=changeme_strong_password_here
# ─── Security ────────────────────────────────────────────────────────────────
# Must be at least 256 bits (32+ characters). Generate one with:
# openssl rand -base64 64
JWT_SECRET=replace_this_with_a_very_long_random_secret_string_minimum_256_bits
# ─── AWS S3 (for screenshot storage) ─────────────────────────────────────────
AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=your_aws_access_key
AWS_SECRET_ACCESS_KEY=your_aws_secret_key
S3_BUCKET_NAME=autopilot-screenshots
# ─── Worker (AutoPilot Engine) ───────────────────────────────────────────────
# URL where the master backend is reachable from the worker container
LOCALAGENT_CLOUD_URL=http://master:9090
LOCALAGENT_POLLING_ENABLED=true

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# AutoPilot — ignore secrets and build artifacts
.env
*.env
# Java build artifacts
**/target/
**/*.class
**/*.jar
!**/mvnw
!**/mvnw.cmd
# Node / Frontend
**/node_modules/
**/dist/
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db
# Logs
*.log
*.log.*
# Data / runtime
**/data/
**/screenshots/
**/DriverFiles/

137
README.md Normal file
View File

@ -0,0 +1,137 @@
# AutoPilot — Distributed Web Test Automation Platform
AutoPilot is a full-stack, enterprise-grade distributed web browser automation platform. It consists of three components deployed together as a monorepo.
```
autopilot/
├── master/ # Cloud Coordinator — Spring Boot REST API + Scheduler + DB
├── worker/ # Execution Engine — Selenium WebDriver agent (polls master)
├── ui/ # Dashboard — React + Vite frontend (served via Nginx)
├── docker-compose.yml # Production: full stack with PostgreSQL
├── docker-compose.local.yml # Local dev: DB only (run services manually)
└── .env.example # Template for environment variables
```
---
## 🚀 Production Deployment (AWS / Any Linux Server)
### Prerequisites
- Docker & Docker Compose installed
- At least 2 CPU cores and 2GB RAM
### 1. Clone the repository
```bash
git clone https://repo.qruize.com/VithobaaSrinivasakumar/Autopilot.git
cd Autopilot
```
### 2. Configure environment variables
```bash
cp .env.example .env
nano .env # Fill in DB_PASS, JWT_SECRET, AWS keys, etc.
```
### 3. Deploy the full stack
```bash
docker-compose up -d --build
```
This starts:
- **PostgreSQL** (database, internal only)
- **AutoPilot Master** on port `9090`
- **AutoPilot UI** on port `80` (Nginx)
### 4. Verify deployment
```bash
docker-compose ps
curl http://localhost:9090/api/health
```
### 5. Updating to a new version
```bash
git pull origin main
docker-compose up -d --build
```
---
## 🏗️ Local Development (No Docker for Services)
### 1. Start only the database
```bash
docker-compose -f docker-compose.local.yml up -d
```
### 2. Start the Master (Terminal 1)
```bash
cd master
./mvnw spring-boot:run
# Runs on http://localhost:9090
```
### 3. Start the UI (Terminal 2)
```bash
cd ui
npm install
npm run dev
# Runs on http://localhost:5173
```
### 4. Start the Worker (Terminal 3)
```bash
cd worker
./mvnw spring-boot:run
# Polls http://localhost:9090 for jobs
```
---
## 🔗 CI/CD Integration
AutoPilot can be triggered directly from your DevOps pipeline using an API key.
### Trigger a test suite
```bash
curl -X POST \
-H "Authorization: Bearer ap_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{"suiteName": "Smoke Tests", "browser": "chrome"}' \
https://your-domain.com/api/v1/suites/trigger-by-name
```
### Poll for results
```bash
curl -H "Authorization: Bearer ap_live_your_api_key" \
https://your-domain.com/api/v1/schedulers/{schedulerId}/execution-status
# exitCode: 0 = all passed, 1 = failures, 2 = still running
```
For full GitHub Actions, GitLab CI, and Jenkins snippets, see the **CI/CD Integration** page inside the dashboard.
---
## 🌐 Environment Variables Reference
| Variable | Description | Required |
|---|---|---|
| `DB_URL` | JDBC connection string to PostgreSQL | ✅ |
| `DB_USER` | Database username | ✅ |
| `DB_PASS` | Database password | ✅ |
| `JWT_SECRET` | Minimum 256-bit secret for signing tokens | ✅ |
| `AWS_REGION` | AWS region for S3 screenshot storage | Optional |
| `AWS_ACCESS_KEY_ID` | AWS access key | Optional |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | Optional |
| `S3_BUCKET_NAME` | S3 bucket for storing screenshots | Optional |
| `LOCALAGENT_CLOUD_URL` | URL the Worker uses to reach the Master | Worker only |
---
## 📖 API Reference
| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/api/v1/suites/{id}/trigger` | Trigger suite by ID |
| `POST` | `/api/v1/suites/trigger-by-name` | Trigger suite by name |
| `GET` | `/api/v1/schedulers/{id}/execution-status` | Poll execution result |
| `GET` | `/api/v1/executions/{id}/status` | Get execution detail |

25
docker-compose.local.yml Normal file
View File

@ -0,0 +1,25 @@
version: '3.8'
# ─── LOCAL DEVELOPMENT OVERRIDE ───────────────────────────────────────────────
# Run with: docker-compose -f docker-compose.yml -f docker-compose.local.yml up
# Or just: docker-compose -f docker-compose.local.yml up
#
# This starts ONLY the database. Master, Worker, and UI are run directly
# from your terminal for fast hot-reloading during development.
services:
postgres:
image: postgres:15-alpine
container_name: autopilot-postgres-local
environment:
POSTGRES_DB: localagent_cloud
POSTGRES_USER: localagent
POSTGRES_PASSWORD: localagent
ports:
- "5432:5432"
volumes:
- postgres_local_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_local_data:

79
docker-compose.yml Normal file
View File

@ -0,0 +1,79 @@
version: '3.8'
services:
# ─── PostgreSQL Database ──────────────────────────────────────────────────
postgres:
image: postgres:15-alpine
container_name: autopilot-postgres
environment:
POSTGRES_DB: localagent_cloud
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d localagent_cloud"]
interval: 10s
timeout: 5s
retries: 5
# ─── AutoPilot Master (Cloud Coordinator Backend) ─────────────────────────
master:
build:
context: ./master
dockerfile: Dockerfile
image: autopilot/master:latest
container_name: autopilot-master
ports:
- "9090:9090"
environment:
- SPRING_PROFILES_ACTIVE=prod
- DB_URL=${DB_URL}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
- AWS_REGION=${AWS_REGION}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
# ─── AutoPilot UI (React Frontend via Nginx) ───────────────────────────────
ui:
build:
context: ./ui
dockerfile: Dockerfile
image: autopilot/ui:latest
container_name: autopilot-ui
ports:
- "80:80"
- "443:443"
depends_on:
- master
restart: unless-stopped
# ─── AutoPilot Worker (Execution Engine) ──────────────────────────────────
# NOTE: The worker needs access to a real browser (Chrome/Firefox).
# For headless browser automation, run the worker on a separate machine
# or a server with Xvfb installed. Uncomment if running on a capable host.
#
# worker:
# build:
# context: ./worker
# dockerfile: Dockerfile
# image: autopilot/worker:latest
# container_name: autopilot-worker
# environment:
# - LOCALAGENT_CLOUD_URL=${LOCALAGENT_CLOUD_URL}
# - LOCALAGENT_POLLING_ENABLED=${LOCALAGENT_POLLING_ENABLED}
# depends_on:
# - master
# restart: unless-stopped
volumes:
postgres_data:

49
master/.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Build outputs
/target/
**/target/
# IDEs
.idea/
.vscode/
*.iml
# Maven wrapper downloads
.mvn/wrapper/maven-wrapper.jar
# Local runtime data
/data/
**/data/
localagent-cloud/data/
# Logs
*.log
*.log.*
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.swp
*.swo
# Java
*.class
*.jar
# Test reports
**/surefire-reports/
# Screenshots and generated artifacts
screenshots/
**/screenshots/
# Vendor / dependency folders if they are generated locally
vendor/
vendor_slim/
# Local agent runtime files
localagent/driver_log.txt
localagent/*.log
localagent/**/*.log

View File

@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip

14
master/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
# Stage 1: Build Backend
FROM maven:3.9.6-eclipse-temurin-21-alpine AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
# Stage 2: Run (minimal JRE image)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/autopilot-master-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 9090
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

79
master/README.md Normal file
View File

@ -0,0 +1,79 @@
# AutoPilot Platform
AutoPilot is a high-performance web browser automation platform designed for distributed execution orchestration. It consists of three core components:
---
## 📂 Project Structure
```text
localagent/
├── autopilot-ui/ # React/Vite Frontend (Web Dashboard)
├── autopilot-master/ # Cloud Coordinator Broker service (Spring Boot)
├── autopilot-worker/ # AutoPropel LocalAgent application runner (Spring Boot)
├── service-packaging/ # Windows Service wrappers and registration scripts
├── docker-compose.yml # Local development database infrastructure
└── pom.xml # Workspace parent POM for the Java projects
```
---
## 🛠️ Prerequisites
- **Java**: JDK 21 or JDK 25 installed and configured on your `PATH`.
- **Node.js**: v18+ (for running the React UI).
- **Browsers**: Google Chrome and/or Mozilla Firefox installed in default system paths.
- **Docker**: For running the local PostgreSQL database.
---
## 🏗️ Local Development Setup
To run the entire distributed platform on your local machine, follow these steps in order:
### 1. Start the Local Database
We use Docker to spin up a local PostgreSQL database for development, so you don't need to connect to AWS.
From the root of the project:
```bash
docker-compose up -d
```
*(This starts PostgreSQL on port `5432` with credentials `user: localagent`, `password: localagent`)*
### 2. Start the Master (Cloud Coordinator)
The Master broker manages the database, serves the API, and schedules jobs.
Open a new terminal at the root of the project:
```bash
.\mvnw.cmd clean spring-boot:run -pl autopilot-master -Dspring-boot.run.profiles=default -DJWT_SECRET=supersecretjwtkeythatismorethan256bits
```
*(The Master will start on port `9090`)*
### 3. Start the UI (React Frontend)
The frontend dashboard allows you to visually interact with the platform.
Open a new terminal at the root of the project:
```bash
cd autopilot-ui
npm install
npm run dev
```
*(The UI will start on port `5173` and proxy API requests to the Master on `9090`)*
### 4. Start the Worker (Execution Engine)
The Worker actually drives the browser and runs the tests. It will automatically connect to your local Master to fetch jobs.
Open a new terminal at the root of the project:
```bash
.\mvnw.cmd clean spring-boot:run -pl autopilot-worker/localagent-java
```
*(The Worker will start on port `8080` and begin polling `http://localhost:9090` for jobs)*
---
## ☁️ Cloud / Production Deployment
When deploying to AWS or another cloud provider:
1. Deploy `autopilot-master` to an EC2 instance or ECS container and point it to a managed PostgreSQL RDS database using `DB_URL`, `DB_USER`, and `DB_PASS`.
2. Build and deploy `autopilot-ui` (e.g. via S3 + CloudFront or an Nginx container).
3. Distribute the `autopilot-worker` to the testing nodes. Set the `LOCALAGENT_CLOUD_URL` environment variable on the workers to point to your public Master API.
Example for Worker:
```bash
set LOCALAGENT_CLOUD_URL=https://api.yourdomain.com
set LOCALAGENT_AGENT_ID=agent_windows_01
```

20
master/docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
version: '3.8'
services:
backend:
build:
context: .
dockerfile: Dockerfile
image: autopilot/backend:latest
container_name: autopilot-backend
ports:
- "9090:9090"
restart: unless-stopped
environment:
- SPRING_PROFILES_ACTIVE=prod
# If you have a local postgres DB running, uncomment and configure:
# - SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/autopilot
# - SPRING_DATASOURCE_USERNAME=postgres
# - SPRING_DATASOURCE_PASSWORD=postgres

11
master/fix.js Normal file
View File

@ -0,0 +1,11 @@
const fs = require('fs');
let content = fs.readFileSync('frontend/src/App.jsx', 'utf8');
// Replace fetch('/api/...) but not /api/auth
content = content.replace(/\bfetch\((['"`])\/api\/(?!auth)/g, 'api($1/api/');
// Replace fetch(url
content = content.replace(/\bfetch\(url/g, 'api(url');
fs.writeFileSync('frontend/src/App.jsx', content);
console.log('App.jsx updated');

295
master/mvnw vendored Normal file
View File

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
master/mvnw.cmd vendored Normal file
View File

@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

94
master/pom.xml Normal file
View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.autopilot</groupId>
<artifactId>autopilot-master</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>autopilot-master</name>
<description>Cloud Coordinator for AutoPilot Local Agent</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AWS SDK for S3 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.25.11</version>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,14 @@
package com.autopilot.localagent_cloud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class LocalagentCloudApplication {
public static void main(String[] args) {
SpringApplication.run(LocalagentCloudApplication.class, args);
}
}

View File

@ -0,0 +1,30 @@
package com.autopilot.localagent_cloud;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MvpUiViewController {
@GetMapping({
"/autopilot",
"/autopilot/",
"/autopilot/login",
"/autopilot/register",
"/autopilot/dashboard",
"/autopilot/scheduler",
"/autopilot/scheduler/**",
"/autopilot/groups",
"/autopilot/groups/**",
"/autopilot/test-cases",
"/autopilot/test-cases/**",
"/autopilot/test-case-groups",
"/autopilot/test-case-groups/**",
"/autopilot/test-suites",
"/autopilot/test-suites/**",
"/autopilot/stub/**"
})
public String dashboardRedirect() {
return "forward:/autopilot/index.html";
}
}

View File

@ -0,0 +1,67 @@
package com.autopilot.localagent_cloud.aiengine;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.autopilot.localagent_cloud.service.ExecutionService;
/**
* Intelligent Decision Engine Layer
* Implements rule-based validation and AI risk models.
*/
@RestController
@RequestMapping("/api/ai-engine")
public class DecisionEngineController {
private final ExecutionService executionService;
public DecisionEngineController(ExecutionService executionService) {
this.executionService = executionService;
}
@SuppressWarnings("unchecked")
@GetMapping("/predict-release-risk")
public ResponseEntity<Map<String, Object>> predictRisk(@RequestParam Long executionId) {
ResponseEntity<Map<String, Object>> res = executionService.getById(executionId);
if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) {
return ResponseEntity.status(res.getStatusCode()).build();
}
Map<String, Object> executionData = res.getBody();
Map<String, Object> execution = (Map<String, Object>) executionData.get("execution");
int passed = execution.get("passedCount") != null ? ((Number) execution.get("passedCount")).intValue() : 0;
int failed = execution.get("failedCount") != null ? ((Number) execution.get("failedCount")).intValue() : 0;
String decision;
double qualityScore;
String[] riskFactors;
if (failed > 0) {
decision = "REJECTED_HIGH_RISK";
qualityScore = 0.0;
riskFactors = new String[]{failed + " tests failed during execution"};
} else if (passed > 0 && failed == 0) {
decision = "APPROVED";
qualityScore = 1.0;
riskFactors = new String[]{"All tests passed successfully"};
} else {
decision = "CONDITIONAL_DEPLOYMENT";
qualityScore = 0.5;
riskFactors = new String[]{"Insufficient test data. " + passed + " passed, " + failed + " failed"};
}
return ResponseEntity.ok(Map.of(
"executionId", executionId,
"qualityScore", qualityScore,
"decision", decision,
"riskFactors", riskFactors
));
}
}

View File

@ -0,0 +1,93 @@
package com.autopilot.localagent_cloud.analytics;
import java.util.List;
import java.util.Map;
import com.autopilot.localagent_cloud.service.AnalyticsService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Data Normalization & Quality Feature Engineering
* Handles aggregated metrics, pass/fail rates, and coverage data.
*/
@RestController
@RequestMapping("/api/analytics")
@CrossOrigin(origins = "*")
public class AnalyticsController {
private final AnalyticsService analyticsService;
public AnalyticsController(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping("/dashboard/summary")
public ResponseEntity<Map<String, Object>> getQualitySummary() {
// TODO: Aggregate test data from DB and calculate pass/fail and coverage rates
return ResponseEntity.ok(Map.of(
"totalTests", 0,
"passRate", 0.0,
"flakyTestsDetected", 0
));
}
/**
* GET /api/analytics/flaky-suites
* Returns the top N flakiest test suites for the authenticated org.
*/
@GetMapping("/flaky-suites")
public ResponseEntity<List<Map<String, Object>>> getFlakySuites(
@RequestParam(defaultValue = "5") int limit,
HttpServletRequest req) {
Long orgId = orgId(req);
List<Map<String, Object>> data = analyticsService.getFlakySuites(orgId, limit);
return ResponseEntity.ok(data);
}
/**
* GET /api/analytics/fleet-health
* Returns live fleet status: agent statuses, counts, and queue depth.
*/
@GetMapping("/fleet-health")
public ResponseEntity<Map<String, Object>> getFleetHealth(HttpServletRequest req) {
Long orgId = orgId(req);
Map<String, Object> data = analyticsService.getFleetHealth(orgId);
return ResponseEntity.ok(data);
}
/**
* GET /api/analytics/suite-performance
* Returns ranked list of test suites by success rate, avg duration, and run count.
*/
@GetMapping("/suite-performance")
public ResponseEntity<List<Map<String, Object>>> getSuitePerformance(
@RequestParam(defaultValue = "10") int limit,
HttpServletRequest req) {
Long orgId = orgId(req);
List<Map<String, Object>> data = analyticsService.getSuitePerformance(orgId, limit);
return ResponseEntity.ok(data);
}
/**
* GET /api/analytics/time-saved-roi
* Returns ROI metrics: hours saved, dollar value saved, runs automated,
* vs estimated manual testing time. Includes 14-day daily breakdown.
*/
@GetMapping("/time-saved-roi")
public ResponseEntity<Map<String, Object>> getTimeSavedRoi(HttpServletRequest req) {
Long orgId = orgId(req);
Map<String, Object> data = analyticsService.getTimeSavedRoi(orgId);
return ResponseEntity.ok(data);
}
}

View File

@ -0,0 +1,28 @@
package com.autopilot.localagent_cloud.auth;
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.autopilot.localagent_cloud.model.AppUser;
import com.autopilot.localagent_cloud.repository.AppUserRepository;
@Service
public class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository;
public AppUserDetailsService(AppUserRepository userRepository) { this.userRepository = userRepository; }
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPasswordHash(),
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
);
}
}

View File

@ -0,0 +1,293 @@
package com.autopilot.localagent_cloud.auth;
import com.autopilot.localagent_cloud.model.*;
import com.autopilot.localagent_cloud.repository.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.HashMap;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "*")
public class AuthController {
private final AppUserRepository userRepository;
private final OrganisationRepository orgRepository;
private final AgentTokenRepository agentTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public AuthController(AppUserRepository userRepository,
OrganisationRepository orgRepository,
AgentTokenRepository agentTokenRepository,
PasswordEncoder passwordEncoder,
JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.orgRepository = orgRepository;
this.agentTokenRepository = agentTokenRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
/** POST /api/auth/register — creates an org + user, returns JWT */
@PostMapping("/register")
public ResponseEntity<Map<String, Object>> register(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
String fullName = body.getOrDefault("fullName", "");
String orgName = body.getOrDefault("orgName", fullName + "'s Organisation");
if (email == null || password == null || email.isBlank() || password.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Email and password are required"));
}
if (userRepository.existsByEmail(email)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "Email already registered"));
}
// Create organisation
Organisation org = new Organisation();
org.setName(orgName);
org.setPlan("trial");
// Generate subdomain
String baseSlug = orgName.toLowerCase().replaceAll("[^a-z0-9]+", "-");
if (baseSlug.startsWith("-")) baseSlug = baseSlug.substring(1);
if (baseSlug.endsWith("-")) baseSlug = baseSlug.substring(0, baseSlug.length() - 1);
if (baseSlug.isBlank()) baseSlug = "org";
String slug = baseSlug;
int counter = 1;
while (orgRepository.findBySubdomain(slug) != null) {
slug = baseSlug + "-" + counter;
counter++;
}
org.setSubdomain(slug);
// Generate unique public ID like VIT-49281
String namePrefix = orgName.replaceAll("[^a-zA-Z0-9]", "").toUpperCase();
namePrefix = namePrefix.length() >= 3 ? namePrefix.substring(0, 3) : namePrefix;
String publicId;
do {
publicId = namePrefix + "-" + String.format("%05d", (int)(Math.random() * 99999));
} while (orgRepository.findByPublicId(publicId) != null);
org.setPublicId(publicId);
org = orgRepository.save(org);
// Create user
AppUser user = new AppUser();
user.setOrgId(org.getId());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(password));
user.setFullName(fullName);
user.setRole("admin");
user = userRepository.save(user);
// Auto-generate first agent token
AgentToken token = new AgentToken();
token.setOrgId(org.getId());
token.setToken("agt_" + UUID.randomUUID().toString().replace("-", ""));
token.setLabel("Default Agent");
agentTokenRepository.save(token);
String jwt = jwtUtil.generateToken(user.getEmail(), org.getId(), user.getRole());
Map<String, Object> registerResp = new HashMap<>();
registerResp.put("token", jwt);
registerResp.put("email", user.getEmail());
registerResp.put("fullName", user.getFullName() != null ? user.getFullName() : "");
registerResp.put("orgId", org.getId());
registerResp.put("orgPublicId", org.getPublicId() != null ? org.getPublicId() : "");
registerResp.put("orgName", org.getName());
registerResp.put("subdomain", org.getSubdomain());
registerResp.put("plan", org.getPlan());
registerResp.put("role", user.getRole());
registerResp.put("agentToken", token.getToken());
registerResp.put("requiresPasswordChange", user.isRequiresPasswordChange());
return ResponseEntity.status(HttpStatus.CREATED).body(registerResp);
}
/** POST /api/auth/login — verify credentials, return JWT */
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
if (email == null || password == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Email and password required"));
}
AppUser user = userRepository.findByEmail(email).orElse(null);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "No such user. Please create an account."));
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Invalid credentials"));
}
Organisation org = orgRepository.findById(user.getOrgId()).orElseThrow();
String agentToken = agentTokenRepository.findByOrgId(org.getId())
.stream().findFirst().map(AgentToken::getToken).orElse("");
String jwt = jwtUtil.generateToken(user.getEmail(), org.getId(), user.getRole());
Map<String, Object> loginResp = new HashMap<>();
loginResp.put("token", jwt);
loginResp.put("email", user.getEmail());
loginResp.put("fullName", user.getFullName() != null ? user.getFullName() : "");
loginResp.put("orgId", org.getId());
loginResp.put("orgPublicId", org.getPublicId() != null ? org.getPublicId() : "");
loginResp.put("orgName", org.getName());
loginResp.put("subdomain", org.getSubdomain());
loginResp.put("plan", org.getPlan());
loginResp.put("role", user.getRole());
loginResp.put("agentToken", agentToken);
loginResp.put("requiresPasswordChange", user.isRequiresPasswordChange());
return ResponseEntity.ok(loginResp);
}
/** GET /api/auth/me — get current user info from JWT */
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me(jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Organisation org = orgRepository.findById(orgId).orElse(null);
if (org == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String agentToken = agentTokenRepository.findByOrgId(orgId)
.stream().findFirst().map(AgentToken::getToken).orElse("");
AppUser currentUser = userRepository.findByEmail((String) req.getAttribute("email")).orElse(null);
return ResponseEntity.ok(Map.of(
"orgId", orgId,
"orgPublicId", org.getPublicId() != null ? org.getPublicId() : "",
"orgName", org.getName(),
"subdomain", org.getSubdomain(),
"plan", org.getPlan(),
"role", currentUser != null ? currentUser.getRole() : "user",
"agentToken", agentToken,
"requiresPasswordChange", currentUser != null && currentUser.isRequiresPasswordChange()
));
}
/** POST /api/auth/agent-tokens — generate a new agent token for the org */
@PostMapping("/agent-tokens")
public ResponseEntity<Map<String, Object>> createAgentToken(
@RequestBody(required = false) Map<String, String> body,
jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
AgentToken token = new AgentToken();
token.setOrgId(orgId);
token.setToken("agt_" + UUID.randomUUID().toString().replace("-", ""));
token.setLabel(body != null ? body.getOrDefault("label", "Agent") : "Agent");
agentTokenRepository.save(token);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"token", token.getToken(),
"label", token.getLabel(),
"id", token.getId()
));
}
/** GET /api/auth/users — list all users in the organization */
@GetMapping("/users")
public ResponseEntity<java.util.List<AppUser>> getOrgUsers(jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(userRepository.findByOrgId(orgId));
}
/** POST /api/auth/users/invite — invite/create a user in the org */
@PostMapping("/users/invite")
public ResponseEntity<Map<String, Object>> inviteUser(
@RequestBody Map<String, String> body,
jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
// Only admins can invite users
String callerEmail = (String) req.getAttribute("email");
AppUser caller = callerEmail != null ? userRepository.findByEmail(callerEmail).orElse(null) : null;
if (caller == null || !"admin".equals(caller.getRole())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "Only administrators can invite users"));
}
String email = body.get("email");
String role = body.getOrDefault("role", "user");
String fullName = body.getOrDefault("fullName", "");
if (email == null || email.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));
}
if (userRepository.existsByEmail(email)) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "Email already registered"));
}
AppUser user = new AppUser();
user.setOrgId(orgId);
user.setEmail(email);
user.setFullName(fullName);
user.setRole(role);
// SECURITY FIX: Generate a stronger temporary password (16-char alphanumeric)
// Industry standard: NEVER return plaintext passwords in API responses.
// The password must be shared out-of-band (e.g., email, secure channel).
// We return it ONCE here as it cannot be recovered later the admin must copy it immediately.
String randomPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
user.setPasswordHash(passwordEncoder.encode(randomPassword));
user.setRequiresPasswordChange(true);
user = userRepository.save(user);
// NOTE: In production with email configured, send this to the user's email instead.
// The field is named 'initialPassword' not 'temporaryPassword' to make it clear this
// is a one-time display it will not appear again after this response.
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"id", user.getId(),
"email", user.getEmail(),
"role", user.getRole(),
"fullName", user.getFullName() != null ? user.getFullName() : "",
"initialPassword", randomPassword,
"notice", "Share this password with the user via a secure channel. It will not be shown again."
));
}
/** GET /api/auth/agent-tokens — list all agent tokens in the org */
@GetMapping("/agent-tokens")
public ResponseEntity<java.util.List<AgentToken>> getOrgAgentTokens(jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(agentTokenRepository.findByOrgId(orgId));
}
@DeleteMapping("/agent-tokens/{id}")
public ResponseEntity<Void> deleteAgentToken(
@PathVariable Long id,
jakarta.servlet.http.HttpServletRequest req) {
Long orgId = (Long) req.getAttribute("orgId");
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return agentTokenRepository.findById(id).map(token -> {
if (token.getOrgId().equals(orgId)) {
agentTokenRepository.delete(token);
return ResponseEntity.ok().<Void>build();
} else {
return ResponseEntity.status(HttpStatus.FORBIDDEN).<Void>build();
}
}).orElse(ResponseEntity.notFound().build());
}
@PostMapping("/change-password")
public ResponseEntity<Map<String, Object>> changePassword(
@RequestBody Map<String, String> body,
jakarta.servlet.http.HttpServletRequest req) {
String email = (String) req.getAttribute("email");
if (email == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String newPassword = body.get("newPassword");
if (newPassword == null || newPassword.length() < 8) {
return ResponseEntity.badRequest().body(Map.of("error", "Password must be at least 8 characters"));
}
AppUser user = userRepository.findByEmail(email).orElse(null);
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setRequiresPasswordChange(false);
userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "Password updated successfully"));
}
}

View File

@ -0,0 +1,70 @@
package com.autopilot.localagent_cloud.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import com.autopilot.localagent_cloud.repository.AppUserRepository;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AppUserRepository userRepository;
private final com.autopilot.localagent_cloud.repository.ApiKeyRepository apiKeyRepository;
public JwtAuthFilter(JwtUtil jwtUtil, AppUserRepository userRepository, com.autopilot.localagent_cloud.repository.ApiKeyRepository apiKeyRepository) {
this.jwtUtil = jwtUtil;
this.userRepository = userRepository;
this.apiKeyRepository = apiKeyRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
String email = jwtUtil.extractEmail(token);
// Verify user actually exists in DB! (so if DB is wiped, token is invalid)
if (userRepository.existsByEmail(email)) {
Long orgId = jwtUtil.extractOrgId(token);
var claims = jwtUtil.extractClaims(token);
String role = claims.get("role", String.class);
// Store orgId and email in request attribute for controllers to read
req.setAttribute("orgId", orgId);
req.setAttribute("email", email);
var auth = new UsernamePasswordAuthenticationToken(
email, null,
List.of(new SimpleGrantedAuthority("ROLE_" + (role != null ? role.toUpperCase() : "USER")))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} else if (token.startsWith("ap_live_")) {
// It's an API Key
apiKeyRepository.findByToken(token).ifPresent(key -> {
// Throttle updates: only save if lastUsedAt is null or older than 5 minutes
java.time.LocalDateTime now = java.time.LocalDateTime.now();
if (key.getLastUsedAt() == null || key.getLastUsedAt().isBefore(now.minusMinutes(5))) {
key.setLastUsedAt(now);
apiKeyRepository.save(key);
}
req.setAttribute("orgId", key.getOrgId());
var auth = new UsernamePasswordAuthenticationToken(
"api-key-" + key.getId(), null,
List.of(new SimpleGrantedAuthority("ROLE_API"))
);
SecurityContextHolder.getContext().setAuthentication(auth);
});
}
}
chain.doFilter(req, res);
}
}

View File

@ -0,0 +1,41 @@
package com.autopilot.localagent_cloud.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtUtil {
private final SecretKey key;
private final long expirationMs;
public JwtUtil(@Value("${autopilot.jwt.secret}") String secret,
@Value("${autopilot.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.expirationMs = expirationMs;
}
public String generateToken(String email, Long orgId, String role) {
return Jwts.builder()
.subject(email)
.claim("orgId", orgId)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact();
}
public Claims extractClaims(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
}
public String extractEmail(String token) { return extractClaims(token).getSubject(); }
public Long extractOrgId(String token) { return extractClaims(token).get("orgId", Long.class); }
public boolean isValid(String token) {
try { extractClaims(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; }
}
}

View File

@ -0,0 +1,137 @@
package com.autopilot.localagent_cloud.auth;
import com.autopilot.localagent_cloud.model.AgentToken;
import com.autopilot.localagent_cloud.repository.AgentTokenRepository;
import com.autopilot.localagent_cloud.model.AppUser;
import com.autopilot.localagent_cloud.repository.AppUserRepository;
import com.autopilot.localagent_cloud.model.DevicePairing;
import com.autopilot.localagent_cloud.repository.DevicePairingRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
@RestController
@RequestMapping("/api")
public class PairingController {
private final DevicePairingRepository pairingRepository;
private final AgentTokenRepository tokenRepository;
private final AppUserRepository userRepository;
public PairingController(DevicePairingRepository pairingRepository, AgentTokenRepository tokenRepository, AppUserRepository userRepository) {
this.pairingRepository = pairingRepository;
this.tokenRepository = tokenRepository;
this.userRepository = userRepository;
}
/**
* Agent calls this to start pairing.
*/
@PostMapping("/agents/pairing/start")
public ResponseEntity<Map<String, Object>> startPairing() {
String code = String.format("%06d", new Random().nextInt(999999));
// Ensure uniqueness
while (pairingRepository.findByPairingCode(code).isPresent()) {
code = String.format("%06d", new Random().nextInt(999999));
}
DevicePairing pairing = new DevicePairing();
pairing.setPairingCode(code);
pairing = pairingRepository.save(pairing);
Map<String, Object> response = new HashMap<>();
response.put("pairingId", pairing.getId());
response.put("code", pairing.getPairingCode());
response.put("expiresAt", pairing.getExpiresAt());
return ResponseEntity.ok(response);
}
/**
* Agent polls this to see if user has verified the code.
*/
@GetMapping("/agents/pairing/{id}/status")
public ResponseEntity<Map<String, Object>> checkStatus(@PathVariable Long id) {
Optional<DevicePairing> opt = pairingRepository.findById(id);
if (opt.isEmpty()) {
return ResponseEntity.notFound().build();
}
DevicePairing pairing = opt.get();
Map<String, Object> response = new HashMap<>();
if (LocalDateTime.now().isAfter(pairing.getExpiresAt()) && "PENDING".equals(pairing.getStatus())) {
pairing.setStatus("EXPIRED");
pairingRepository.save(pairing);
}
response.put("status", pairing.getStatus());
if ("PAIRED".equals(pairing.getStatus())) {
response.put("token", pairing.getAgentToken());
}
return ResponseEntity.ok(response);
}
/**
* User (via Web Dashboard) verifies the 6 digit code.
*/
@PostMapping("/pairing/verify")
public ResponseEntity<Map<String, String>> verifyCode(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Code is required"));
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();
Optional<AppUser> userOpt = userRepository.findByEmail(email);
if (userOpt.isEmpty()) {
return ResponseEntity.status(401).build();
}
Long orgId = userOpt.get().getOrgId();
Optional<DevicePairing> pairingOpt = pairingRepository.findByPairingCode(code);
if (pairingOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid pairing code"));
}
DevicePairing pairing = pairingOpt.get();
if (!"PENDING".equals(pairing.getStatus())) {
return ResponseEntity.badRequest().body(Map.of("error", "Code is already paired or expired"));
}
if (LocalDateTime.now().isAfter(pairing.getExpiresAt())) {
pairing.setStatus("EXPIRED");
pairingRepository.save(pairing);
return ResponseEntity.badRequest().body(Map.of("error", "Code has expired"));
}
// Generate a new Agent Token for the organization
AgentToken newToken = new AgentToken();
newToken.setOrgId(orgId);
newToken.setToken("agt_" + UUID.randomUUID().toString().replace("-", ""));
newToken.setLabel("Agent paired via device code");
tokenRepository.save(newToken);
// Mark pairing as complete
pairing.setStatus("PAIRED");
pairing.setOrgId(orgId);
pairing.setAgentToken(newToken.getToken());
pairingRepository.save(pairing);
return ResponseEntity.ok(Map.of("message", "Agent successfully paired"));
}
}

View File

@ -0,0 +1,55 @@
package com.autopilot.localagent_cloud.auth;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; }
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex.authenticationEntryPoint((req, res, e) -> res.sendError(jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")))
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/auth/**").permitAll()
// Agent polling uses token-based auth (not JWT), so permit it
.requestMatchers("/api/agents/*/jobs/next").permitAll()
.requestMatchers("/api/agents/register").permitAll()
.requestMatchers("/api/agents/*/heartbeat").permitAll()
.requestMatchers("/api/agents/pairing/**").permitAll()
.requestMatchers("/api/executions/*/results").permitAll()
// Legacy agent endpoints
.requestMatchers("/agents/**").permitAll()
.requestMatchers("/executions/**").permitAll()
// Static frontend and assets
.requestMatchers("/autopilot/**", "/", "/index.html").permitAll()
.requestMatchers("/assets/**", "/*.js", "/*.css", "/*.json", "/*.png", "/*.svg", "/*.ico").permitAll()
.requestMatchers("/*.ps1", "/*.sh", "/agent/**").permitAll()
// Everything else requires JWT
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@ -0,0 +1,48 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Agent;
import com.autopilot.localagent_cloud.model.AgentGroupMapping;
import com.autopilot.localagent_cloud.service.AgentService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/agents")
@CrossOrigin(origins = "*")
public class AgentController {
private final AgentService agentService;
public AgentController(AgentService agentService) {
this.agentService = agentService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Agent>> getAgents(HttpServletRequest req) {
return agentService.getAll(orgId(req));
}
@PostMapping("/register")
public ResponseEntity<Map<String, Object>> registerAgent(@RequestBody Map<String, Object> body) {
return agentService.register(body);
}
@PostMapping("/{id}/heartbeat")
public ResponseEntity<Void> agentHeartbeat(@PathVariable("id") String id) {
return agentService.heartbeat(id);
}
@GetMapping("/{id}/jobs/next")
public ResponseEntity<Map<String, Object>> getNextJob(@PathVariable("id") String agentId) {
return agentService.getNextJob(agentId);
}
}

View File

@ -0,0 +1,91 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.ApiKey;
import com.autopilot.localagent_cloud.model.AuditLog;
import com.autopilot.localagent_cloud.repository.ApiKeyRepository;
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/apikeys")
public class ApiKeyController {
private final ApiKeyRepository apiKeyRepository;
private final AuditLogRepository auditLogRepository;
public ApiKeyController(ApiKeyRepository apiKeyRepository, AuditLogRepository auditLogRepository) {
this.apiKeyRepository = apiKeyRepository;
this.auditLogRepository = auditLogRepository;
}
private String getUserEmail(HttpServletRequest req) {
Object e = req.getAttribute("email");
return e != null ? e.toString() : "API_KEY";
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<ApiKey>> getKeys(HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(apiKeyRepository.findByOrgId(orgId));
}
@PostMapping
public ResponseEntity<?> createKey(@RequestBody Map<String, String> body, HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String name = body.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Key name cannot be blank."));
}
ApiKey key = new ApiKey();
key.setOrgId(orgId);
key.setName(name);
key.setToken("ap_live_" + UUID.randomUUID().toString().replace("-", ""));
ApiKey saved = apiKeyRepository.save(key);
auditLogRepository.save(new AuditLog(orgId, getUserEmail(req), "CREATE", "API_KEY", saved.getId().toString(), "Generated API Key: " + saved.getName()));
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteKey(@PathVariable Long id, HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return apiKeyRepository.findById(id).map(key -> {
if (!key.getOrgId().equals(orgId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).<Void>build();
}
apiKeyRepository.delete(key);
auditLogRepository.save(new AuditLog(orgId, getUserEmail(req), "DELETE", "API_KEY", key.getId().toString(), "Revoked API Key: " + key.getName()));
return ResponseEntity.noContent().<Void>build();
}).orElse(ResponseEntity.notFound().build());
}
/**
* Called internally (e.g., by future API middleware) to record when an API key was last used.
* Fix: api_keys.last_used_at was never being updated anywhere.
*/
public void recordKeyUsage(String token) {
apiKeyRepository.findByToken(token).ifPresent(key -> {
key.setLastUsedAt(LocalDateTime.now());
apiKeyRepository.save(key);
});
}
}

View File

@ -0,0 +1,35 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.AuditLog;
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/audit-logs")
public class AuditLogController {
private final AuditLogRepository auditLogRepository;
public AuditLogController(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<AuditLog>> getAuditLogs(HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
List<AuditLog> logs = auditLogRepository.findByOrgIdOrderByCreatedAtDesc(orgId);
return ResponseEntity.ok(logs);
}
}

View File

@ -0,0 +1,215 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Dataset;
import com.autopilot.localagent_cloud.repository.DatasetRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/datasets")
@CrossOrigin(origins = "*")
public class DatasetController {
private final DatasetRepository datasetRepository;
public DatasetController(DatasetRepository datasetRepository) {
this.datasetRepository = datasetRepository;
}
private Long getOrgId(HttpServletRequest request) {
Object o = request.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
// GET /api/datasets list all datasets for org
@GetMapping
public ResponseEntity<List<Dataset>> getAll(HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(datasetRepository.findByOrgId(orgId));
}
// GET /api/datasets/{id} get single dataset by id
@GetMapping("/{id}")
public ResponseEntity<Dataset> getById(@PathVariable Long id, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return datasetRepository.findById(id)
.filter(d -> orgId.equals(d.getOrgId()))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/datasets create dataset from JSON body
@PostMapping
public ResponseEntity<Dataset> create(@RequestBody Dataset dataset, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
dataset.setOrgId(orgId);
return ResponseEntity.ok(datasetRepository.save(dataset));
}
// POST /api/datasets/upload create dataset from CSV file upload
@PostMapping("/upload")
public ResponseEntity<?> uploadCsv(@RequestParam("file") MultipartFile file,
@RequestParam("name") String name,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
List<String[]> allRows = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null) {
if (!line.trim().isEmpty()) {
allRows.add(parseCsvLine(line));
}
}
if (allRows.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "CSV file is empty"));
}
String[] headerRow = allRows.get(0);
List<String[]> dataRows = allRows.subList(1, allRows.size());
String headersJson = toJsonArray(headerRow);
String rowsJson = toJsonArrayOfArrays(dataRows);
Dataset dataset = new Dataset();
dataset.setOrgId(orgId);
dataset.setName(name);
dataset.setHeaders(headersJson);
dataset.setRows(rowsJson);
dataset.setRowCount(dataRows.size());
return ResponseEntity.ok(datasetRepository.save(dataset));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to parse CSV: " + e.getMessage()));
}
}
// PUT /api/datasets/{id} update dataset
@PutMapping("/{id}")
public ResponseEntity<Dataset> update(@PathVariable Long id,
@RequestBody Dataset body,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return datasetRepository.findById(id)
.filter(d -> orgId.equals(d.getOrgId()))
.map(d -> {
if (body.getName() != null) d.setName(body.getName());
if (body.getDescription() != null) d.setDescription(body.getDescription());
if (body.getHeaders() != null) d.setHeaders(body.getHeaders());
if (body.getRows() != null) d.setRows(body.getRows());
if (body.getRowCount() != null) d.setRowCount(body.getRowCount());
return ResponseEntity.ok(datasetRepository.save(d));
}).orElse(ResponseEntity.notFound().build());
}
// DELETE /api/datasets/{id} delete dataset
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return datasetRepository.findById(id)
.filter(d -> orgId.equals(d.getOrgId()))
.map(d -> { datasetRepository.delete(d); return ResponseEntity.ok().<Void>build(); })
.orElse(ResponseEntity.notFound().build());
}
// --- CSV Parsing Helpers ---
/**
* Parses a single CSV line, handling quoted fields that may contain commas.
*/
private String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (inQuotes) {
if (c == '"') {
// Check for escaped quote ""
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
current.append('"');
i++;
} else {
inQuotes = false;
}
} else {
current.append(c);
}
} else {
if (c == '"') {
inQuotes = true;
} else if (c == ',') {
fields.add(current.toString().trim());
current = new StringBuilder();
} else {
current.append(c);
}
}
}
fields.add(current.toString().trim());
return fields.toArray(new String[0]);
}
/**
* Converts a String array to a JSON array string, e.g. ["a","b","c"]
*/
private String toJsonArray(String[] items) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < items.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(escapeJson(items[i])).append("\"");
}
sb.append("]");
return sb.toString();
}
/**
* Converts a list of String arrays to a JSON array of arrays string.
*/
private String toJsonArrayOfArrays(List<String[]> rows) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < rows.size(); i++) {
if (i > 0) sb.append(",");
sb.append(toJsonArray(rows.get(i)));
}
sb.append("]");
return sb.toString();
}
/**
* Escapes special characters for JSON string values.
*/
private String escapeJson(String value) {
if (value == null) return "";
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}

View File

@ -0,0 +1,133 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.AuditLog;
import com.autopilot.localagent_cloud.model.Environment;
import com.autopilot.localagent_cloud.model.Variable;
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
import com.autopilot.localagent_cloud.repository.EnvironmentRepository;
import com.autopilot.localagent_cloud.repository.VariableRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** Secret masking sentinel — a clean ASCII constant, never a corrupted UTF-8 literal */
@RestController
@RequestMapping("/api/environments")
public class EnvironmentController {
// Industry-standard sentinel string for masked secrets (never stored in DB)
private static final String SECRET_MASK = "••••••••";
private final EnvironmentRepository environmentRepository;
private final VariableRepository variableRepository;
private final AuditLogRepository auditLogRepository;
public EnvironmentController(EnvironmentRepository environmentRepository,
VariableRepository variableRepository,
AuditLogRepository auditLogRepository) {
this.environmentRepository = environmentRepository;
this.variableRepository = variableRepository;
this.auditLogRepository = auditLogRepository;
}
private String getUserEmail(HttpServletRequest req) {
Object e = req.getAttribute("email");
return e != null ? e.toString() : "SYSTEM";
}
private Long getOrgId(HttpServletRequest request) {
// Use the orgId already extracted and validated by JwtAuthFilter consistent with all other controllers
Object o = request.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Environment>> getAll(HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(environmentRepository.findByOrgId(orgId));
}
@PostMapping
public ResponseEntity<Environment> create(@RequestBody Environment env, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
env.setOrgId(orgId);
Environment saved = environmentRepository.save(env);
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "CREATE", "ENVIRONMENT", saved.getId().toString(), "Created Environment: " + saved.getName()));
return ResponseEntity.ok(saved);
}
@PutMapping("/{id}")
public ResponseEntity<Environment> update(@PathVariable Long id,
@RequestBody Environment body,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return environmentRepository.findById(id)
.filter(e -> orgId.equals(e.getOrgId())) // Tenant isolation check
.map(e -> {
e.setName(body.getName());
e.setDescription(body.getDescription());
Environment saved = environmentRepository.save(e);
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "UPDATE", "ENVIRONMENT", saved.getId().toString(), "Updated Environment: " + saved.getName()));
return ResponseEntity.ok(saved);
}).orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
// Bug fix: verify this environment belongs to the calling org before deleting
Environment env = environmentRepository.findById(id).orElse(null);
if (env == null) return ResponseEntity.notFound().build();
if (!orgId.equals(env.getOrgId())) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
// Bug fix: pass correct orgId (was passing null could delete other org's variables!)
List<Variable> envVars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id);
variableRepository.deleteAll(envVars);
environmentRepository.deleteById(id);
auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "DELETE", "ENVIRONMENT", id.toString(), "Deleted Environment: " + env.getName()));
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/variables")
public ResponseEntity<List<Variable>> getVariables(@PathVariable Long id, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
List<Variable> vars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id);
// Mask secret values before returning to client
vars.forEach(v -> { if (Boolean.TRUE.equals(v.getIsSecret())) v.setValue(SECRET_MASK); });
return ResponseEntity.ok(vars);
}
@PostMapping("/{id}/variables")
public ResponseEntity<Variable> addVariable(@PathVariable Long id,
@RequestBody Variable variable,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
variable.setOrgId(orgId);
variable.setScope("ENVIRONMENT");
variable.setScopeId(id);
return ResponseEntity.ok(variableRepository.save(variable));
}
@DeleteMapping("/{envId}/variables/{varId}")
public ResponseEntity<Void> deleteVariable(@PathVariable Long envId,
@PathVariable Long varId,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
// Verify the variable belongs to this org before deleting
return variableRepository.findById(varId)
.filter(v -> orgId.equals(v.getOrgId()))
.map(v -> { variableRepository.delete(v); return ResponseEntity.ok().<Void>build(); })
.orElse(ResponseEntity.notFound().build());
}
}

View File

@ -0,0 +1,62 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Execution;
import com.autopilot.localagent_cloud.service.AgentService;
import com.autopilot.localagent_cloud.service.ExecutionService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/executions")
@CrossOrigin(origins = "*")
public class ExecutionController {
private final ExecutionService executionService;
private final AgentService agentService;
public ExecutionController(ExecutionService executionService, AgentService agentService) {
this.executionService = executionService;
this.agentService = agentService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Execution>> getExecutions(HttpServletRequest req) {
return executionService.getAll(orgId(req));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getExecutionDetail(@PathVariable("id") Long id) {
return executionService.getById(id);
}
@PostMapping("/{id}/results")
public ResponseEntity<Void> postExecutionResults(
@PathVariable("id") Long executionId,
@RequestBody Map<String, Object> result) {
return agentService.postResults(executionId, result);
}
@PostMapping("/{id}/stop")
public ResponseEntity<Void> stopExecution(@PathVariable("id") Long executionId) {
return agentService.stopExecution(executionId);
}
@PostMapping("/{id}/rerun")
public ResponseEntity<Void> rerunExecution(@PathVariable("id") Long executionId) {
return agentService.rerunExecution(executionId);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteExecution(@PathVariable("id") Long executionId) {
return executionService.deleteById(executionId);
}
}

View File

@ -0,0 +1,67 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Group;
import com.autopilot.localagent_cloud.model.AgentGroupMapping;
import com.autopilot.localagent_cloud.service.GroupService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/groups")
@CrossOrigin(origins = "*")
public class GroupController {
private final GroupService groupService;
public GroupController(GroupService groupService) {
this.groupService = groupService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Group>> getGroups(HttpServletRequest req) {
return groupService.getAll(orgId(req));
}
@PostMapping
public ResponseEntity<Group> createGroup(@RequestBody Group group, HttpServletRequest req) {
return groupService.create(group, orgId(req));
}
@PutMapping("/{id}")
public ResponseEntity<Group> updateGroup(@PathVariable("id") Long id, @RequestBody Group updated) {
return groupService.update(id, updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGroup(@PathVariable("id") Long id) {
return groupService.delete(id);
}
@GetMapping("/{id}/agents")
public ResponseEntity<List<Map<String, Object>>> getAgentsInGroup(@PathVariable("id") Long groupId) {
return groupService.getAgentsInGroup(groupId);
}
@PostMapping("/{id}/agents")
public ResponseEntity<AgentGroupMapping> addAgentToGroup(
@PathVariable("id") Long groupId,
@RequestBody Map<String, String> body) {
return groupService.addAgent(groupId, body.get("agentId"));
}
@DeleteMapping("/{groupId}/agents/{agentId}")
public ResponseEntity<Void> removeAgentFromGroup(
@PathVariable("groupId") Long groupId,
@PathVariable("agentId") String agentId) {
return groupService.removeAgent(groupId, agentId);
}
}

View File

@ -0,0 +1,228 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.TestSuite;
import com.autopilot.localagent_cloud.repository.TestSuiteRepository;
import com.autopilot.localagent_cloud.service.ExecutionService;
import com.autopilot.localagent_cloud.service.TestSuiteService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* Gap 4: CI/CD Pipeline Integration Controller
*
* Provides API-key-authenticated endpoints that DevOps tools (Jenkins, GitHub Actions,
* GitLab CI) can call to trigger test suite executions and poll for results.
*
* Authentication: Pass your AutoPilot API Key in the Authorization header:
* Authorization: Bearer ap_live_<your_key>
*/
@RestController
@RequestMapping("/api/v1")
public class PipelineController {
private final TestSuiteService testSuiteService;
private final ExecutionService executionService;
private final TestSuiteRepository testSuiteRepository;
public PipelineController(TestSuiteService testSuiteService,
ExecutionService executionService,
TestSuiteRepository testSuiteRepository) {
this.testSuiteService = testSuiteService;
this.executionService = executionService;
this.testSuiteRepository = testSuiteRepository;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
/**
* Trigger a test suite by its ID.
* Supports optional body overrides: browser, environmentId, targetGroupId.
*
* POST /api/v1/suites/{id}/trigger
* Body (optional):
* {
* "browser": "firefox",
* "environmentId": 2,
* "targetGroupId": 1
* }
*/
@PostMapping("/suites/{id}/trigger")
public ResponseEntity<Map<String, Object>> triggerSuiteById(
@PathVariable("id") Long id,
@RequestBody(required = false) Map<String, Object> overrides,
HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Map<String, Object> runBody = overrides != null ? new HashMap<>(overrides) : new HashMap<>();
return executeAndBuildResponse(id, runBody, orgId);
}
/**
* Trigger a test suite by its exact name. Useful for CI scripts where you know
* the suite name but not the numeric ID.
*
* POST /api/v1/suites/trigger-by-name
* Body:
* {
* "suiteName": "Smoke Tests",
* "browser": "chrome",
* "environmentId": 1
* }
*/
@PostMapping("/suites/trigger-by-name")
public ResponseEntity<Map<String, Object>> triggerSuiteByName(
@RequestBody Map<String, Object> body,
HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String suiteName = (String) body.get("suiteName");
if (suiteName == null || suiteName.isBlank()) {
Map<String, Object> err = new HashMap<>();
err.put("error", "suiteName is required");
return ResponseEntity.badRequest().body(err);
}
Optional<TestSuite> suite = testSuiteRepository.findAll().stream()
.filter(s -> orgId.equals(s.getOrgId()) && suiteName.equalsIgnoreCase(s.getName()))
.findFirst();
if (suite.isEmpty()) {
Map<String, Object> err = new HashMap<>();
err.put("error", "Test Suite not found: " + suiteName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(err);
}
Map<String, Object> runBody = new HashMap<>(body);
runBody.remove("suiteName");
return executeAndBuildResponse(suite.get().getId(), runBody, orgId);
}
/**
* Poll the status of an execution by its ID.
* CI tools can call this in a loop to wait for completion.
*
* GET /api/v1/executions/{id}/status
*/
@SuppressWarnings("unchecked")
@GetMapping("/executions/{id}/status")
public ResponseEntity<Map<String, Object>> getExecutionStatus(
@PathVariable("id") Long id,
HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
ResponseEntity<Map<String, Object>> res = executionService.getById(id);
if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) {
Map<String, Object> queuedResponse = new HashMap<>();
queuedResponse.put("status", "queued");
queuedResponse.put("message", "Waiting for agent to pick up the job...");
return ResponseEntity.ok(queuedResponse);
}
return ResponseEntity.ok(buildExecutionStatusResponse(res.getBody()));
}
/**
* Poll the latest execution status for a scheduler/trigger job.
*
* GET /api/v1/schedulers/{id}/execution-status
*/
@SuppressWarnings("unchecked")
@GetMapping("/schedulers/{id}/execution-status")
public ResponseEntity<Map<String, Object>> getSchedulerExecutionStatus(
@PathVariable("id") Long id,
HttpServletRequest req) {
Long orgId = orgId(req);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
ResponseEntity<Map<String, Object>> res = executionService.getBySchedulerId(id);
if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) {
Map<String, Object> queuedResponse = new HashMap<>();
queuedResponse.put("status", "queued");
queuedResponse.put("message", "Waiting for Local Agent to pick up the job...");
return ResponseEntity.ok(queuedResponse);
}
return ResponseEntity.ok(buildExecutionStatusResponse(res.getBody()));
}
//
private ResponseEntity<Map<String, Object>> executeAndBuildResponse(Long suiteId, Map<String, Object> runBody, Long orgId) {
try {
ResponseEntity<Map<String, Object>> res = testSuiteService.run(suiteId, runBody, orgId);
if (res.getStatusCode() == HttpStatus.OK || res.getStatusCode() == HttpStatus.CREATED) {
Map<String, Object> resBody = res.getBody();
if (resBody != null && resBody.containsKey("scheduler")) {
com.autopilot.localagent_cloud.model.Scheduler sched =
(com.autopilot.localagent_cloud.model.Scheduler) resBody.get("scheduler");
Map<String, Object> response = new HashMap<>();
response.put("schedulerId", sched.getId());
response.put("suiteId", suiteId);
response.put("status", "queued");
response.put("message", "Test Suite queued successfully.");
response.put("statusUrl", "/api/v1/schedulers/" + sched.getId() + "/execution-status");
return ResponseEntity.ok(response);
}
}
return ResponseEntity.status(res.getStatusCode()).body(res.getBody());
} catch (Exception e) {
Map<String, Object> err = new HashMap<>();
err.put("error", "Failed to trigger Test Suite");
err.put("details", e.getMessage());
return ResponseEntity.badRequest().body(err);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> buildExecutionStatusResponse(Map<String, Object> executionData) {
com.autopilot.localagent_cloud.model.Execution execution =
(com.autopilot.localagent_cloud.model.Execution) executionData.get("execution");
java.util.List<com.autopilot.localagent_cloud.model.StepResult> steps =
(java.util.List<com.autopilot.localagent_cloud.model.StepResult>) executionData.get("steps");
int passed = 0, failed = 0;
if (steps != null) {
for (com.autopilot.localagent_cloud.model.StepResult step : steps) {
if (step.getResultStatus() != null && step.getResultStatus() == 1) passed++;
else if (step.getResultStatus() != null && step.getResultStatus() == 2) failed++;
}
}
int total = passed + failed;
double passPercentage = total > 0 ? ((double) passed / total) * 100.0 : 0.0;
long durationMs = 0;
if (execution.getCreatedAt() != null && execution.getFinishedAt() != null) {
durationMs = java.time.Duration.between(execution.getCreatedAt(), execution.getFinishedAt()).toMillis();
}
// CI/CD friendly exit code: 0 = success, 1 = failed, 2 = still running
int exitCode = "completed".equals(execution.getStatus()) ? (failed > 0 ? 1 : 0) : 2;
Map<String, Object> response = new HashMap<>();
response.put("executionId", execution.getId());
response.put("status", execution.getStatus());
response.put("exitCode", exitCode);
response.put("passedCount", passed);
response.put("failedCount", failed);
response.put("totalCount", total);
response.put("passPercentage", Math.round(passPercentage * 100.0) / 100.0);
response.put("durationMs", durationMs);
response.put("aiAnalysis", execution.getAiAnalysis());
response.put("startedAt", execution.getCreatedAt());
response.put("completedAt", execution.getFinishedAt());
return response;
}
}

View File

@ -0,0 +1,33 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Organisation;
import com.autopilot.localagent_cloud.repository.OrganisationRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/public/orgs")
@CrossOrigin(origins = "*")
public class PublicOrgController {
private final OrganisationRepository orgRepository;
public PublicOrgController(OrganisationRepository orgRepository) {
this.orgRepository = orgRepository;
}
@GetMapping("/by-subdomain/{subdomain}")
public ResponseEntity<Map<String, String>> getOrgBySubdomain(@PathVariable String subdomain) {
Organisation org = orgRepository.findBySubdomain(subdomain);
if (org == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(Map.of(
"name", org.getName(),
"subdomain", org.getSubdomain()
));
}
}

View File

@ -0,0 +1,47 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Scheduler;
import com.autopilot.localagent_cloud.service.SchedulerService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/schedulers")
@CrossOrigin(origins = "*")
public class SchedulerController {
private final SchedulerService schedulerService;
public SchedulerController(SchedulerService schedulerService) {
this.schedulerService = schedulerService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Scheduler>> getSchedulers(HttpServletRequest req) {
return schedulerService.getAll(orgId(req));
}
@PostMapping
public ResponseEntity<Scheduler> createScheduler(@RequestBody Map<String, Object> body, HttpServletRequest req) {
return schedulerService.create(body, orgId(req));
}
@PutMapping("/{id}")
public ResponseEntity<Scheduler> updateScheduler(@PathVariable("id") Long id, @RequestBody Map<String, Object> body) {
return schedulerService.update(id, body);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteScheduler(@PathVariable("id") Long id) {
return schedulerService.delete(id);
}
}

View File

@ -0,0 +1,64 @@
package com.autopilot.localagent_cloud.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;
import java.net.MalformedURLException;
import java.nio.file.Path;
import java.nio.file.Paths;
@RestController
@RequestMapping("/api/screenshots")
public class ScreenshotController {
private static final Logger logger = LoggerFactory.getLogger(ScreenshotController.class);
private static final Path SCREENSHOTS_DIR = Paths.get("data/screenshots").toAbsolutePath().normalize();
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping("/{fileName:.+}")
public ResponseEntity<Resource> getScreenshotFile(
@PathVariable("fileName") String fileName,
HttpServletRequest req) {
// Security: require authenticated user
if (orgId(req) == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
// Security: prevent path traversal by resolving inside the base dir and verifying it stays within
Path resolvedPath = SCREENSHOTS_DIR.resolve(fileName).normalize();
if (!resolvedPath.startsWith(SCREENSHOTS_DIR)) {
logger.warn("Path traversal attempt blocked: {}", fileName);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
File file = resolvedPath.toFile();
if (!file.exists() || !file.isFile()) {
return ResponseEntity.notFound().build();
}
Resource resource = new UrlResource(file.toURI());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + file.getName() + "\"")
.contentType(MediaType.IMAGE_PNG)
.body(resource);
} catch (MalformedURLException e) {
logger.error("Failed to serve screenshot file: {}", fileName, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@ -0,0 +1,55 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.TestCase;
import com.autopilot.localagent_cloud.service.TestCaseService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/test-cases")
@CrossOrigin(origins = "*")
public class TestCaseController {
private final TestCaseService testCaseService;
public TestCaseController(TestCaseService testCaseService) {
this.testCaseService = testCaseService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<TestCase>> getTestCases(HttpServletRequest req) {
return testCaseService.getAll(orgId(req));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getTestCaseDetail(@PathVariable("id") Long id) {
return testCaseService.getById(id);
}
@PostMapping
public ResponseEntity<Map<String, Object>> createTestCase(
@RequestBody Map<String, Object> body, HttpServletRequest req) {
return testCaseService.create(body, orgId(req));
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> updateTestCase(
@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
return testCaseService.update(id, body);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTestCase(@PathVariable("id") Long id) {
return testCaseService.delete(id);
}
}

View File

@ -0,0 +1,55 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.TestCaseGroup;
import com.autopilot.localagent_cloud.service.TestCaseGroupService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/test-case-groups")
@CrossOrigin(origins = "*")
public class TestCaseGroupController {
private final TestCaseGroupService testCaseGroupService;
public TestCaseGroupController(TestCaseGroupService testCaseGroupService) {
this.testCaseGroupService = testCaseGroupService;
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<TestCaseGroup>> getTestCaseGroups(HttpServletRequest req) {
return testCaseGroupService.getAll(orgId(req));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getTestCaseGroupDetail(@PathVariable("id") Long id) {
return testCaseGroupService.getById(id);
}
@PostMapping
public ResponseEntity<Map<String, Object>> createTestCaseGroup(
@RequestBody Map<String, Object> body, HttpServletRequest req) {
return testCaseGroupService.create(body, orgId(req));
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> updateTestCaseGroup(
@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
return testCaseGroupService.update(id, body);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTestCaseGroup(@PathVariable("id") Long id) {
return testCaseGroupService.delete(id);
}
}

View File

@ -0,0 +1,86 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.AuditLog;
import com.autopilot.localagent_cloud.model.TestSuite;
import com.autopilot.localagent_cloud.repository.AuditLogRepository;
import com.autopilot.localagent_cloud.service.TestSuiteService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/test-suites")
@CrossOrigin(origins = "*")
public class TestSuiteController {
private final TestSuiteService testSuiteService;
private final AuditLogRepository auditLogRepository;
public TestSuiteController(TestSuiteService testSuiteService, AuditLogRepository auditLogRepository) {
this.testSuiteService = testSuiteService;
this.auditLogRepository = auditLogRepository;
}
private String getUserEmail(HttpServletRequest req) {
Object e = req.getAttribute("email");
return e != null ? e.toString() : "SYSTEM";
}
private Long orgId(HttpServletRequest req) {
Object o = req.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<TestSuite>> getTestSuites(HttpServletRequest req) {
return testSuiteService.getAll(orgId(req));
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getTestSuiteDetail(@PathVariable("id") Long id) {
return testSuiteService.getById(id);
}
@PostMapping
public ResponseEntity<Map<String, Object>> createTestSuite(
@RequestBody Map<String, Object> body, HttpServletRequest req) {
ResponseEntity<Map<String, Object>> res = testSuiteService.create(body, orgId(req));
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
TestSuite suite = (TestSuite) res.getBody().get("suite");
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "CREATE", "TEST_SUITE", suite.getId().toString(), "Created Test Suite: " + suite.getName()));
}
return res;
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> updateTestSuite(
@PathVariable("id") Long id,
@RequestBody Map<String, Object> body, HttpServletRequest req) {
ResponseEntity<Map<String, Object>> res = testSuiteService.update(id, body);
if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) {
TestSuite suite = (TestSuite) res.getBody().get("suite");
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "UPDATE", "TEST_SUITE", suite.getId().toString(), "Updated Test Suite: " + suite.getName()));
}
return res;
}
@PostMapping("/{id}/run")
public ResponseEntity<Map<String, Object>> runTestSuite(
@PathVariable("id") Long id,
@RequestBody(required = false) Map<String, Object> body,
HttpServletRequest req) {
return testSuiteService.run(id, body, orgId(req));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTestSuite(@PathVariable("id") Long id, HttpServletRequest req) {
ResponseEntity<Void> res = testSuiteService.delete(id);
if (res.getStatusCode().is2xxSuccessful()) {
auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "DELETE", "TEST_SUITE", id.toString(), "Deleted Test Suite ID: " + id));
}
return res;
}
}

View File

@ -0,0 +1,93 @@
package com.autopilot.localagent_cloud.controller;
import com.autopilot.localagent_cloud.model.Variable;
import com.autopilot.localagent_cloud.repository.VariableRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/variables")
public class VariableController {
// Industry-standard sentinel used to detect "user did not change the secret value"
private static final String SECRET_MASK = "••••••••";
private final VariableRepository variableRepository;
public VariableController(VariableRepository variableRepository) {
this.variableRepository = variableRepository;
}
private Long getOrgId(HttpServletRequest request) {
// Use the orgId already extracted and validated by JwtAuthFilter
Object o = request.getAttribute("orgId");
return o != null ? ((Number) o).longValue() : null;
}
@GetMapping
public ResponseEntity<List<Variable>> getAll(
@RequestParam(required = false) String scope,
@RequestParam(required = false) Long scopeId,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
List<Variable> vars;
if (scope != null && scopeId != null) {
vars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, scope.toUpperCase(), scopeId);
} else if (scope != null) {
vars = variableRepository.findByOrgIdAndScope(orgId, scope.toUpperCase());
} else {
vars = variableRepository.findByOrgId(orgId);
}
// Bug fix: mask secret values using the clean constant (was a corrupted UTF-8 literal before)
vars.forEach(v -> { if (Boolean.TRUE.equals(v.getIsSecret())) v.setValue(SECRET_MASK); });
return ResponseEntity.ok(vars);
}
@PostMapping
public ResponseEntity<Variable> create(@RequestBody Variable variable, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
variable.setOrgId(orgId);
if (variable.getScope() == null) variable.setScope("GLOBAL");
return ResponseEntity.ok(variableRepository.save(variable));
}
@PutMapping("/{id}")
public ResponseEntity<Variable> update(@PathVariable Long id,
@RequestBody Variable body,
HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return variableRepository.findById(id)
.filter(v -> orgId.equals(v.getOrgId())) // Tenant isolation check
.map(v -> {
v.setKeyName(body.getKeyName());
// Bug fix: only update value if it's not the sentinel mask string
// (was broken before because the corrupted UTF-8 literal never matched)
if (body.getValue() != null && !SECRET_MASK.equals(body.getValue())) {
v.setValue(body.getValue());
}
v.setScope(body.getScope() != null ? body.getScope() : v.getScope());
v.setScopeId(body.getScopeId());
v.setIsSecret(body.getIsSecret());
return ResponseEntity.ok(variableRepository.save(v));
}).orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, HttpServletRequest request) {
Long orgId = getOrgId(request);
if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return variableRepository.findById(id)
.filter(v -> orgId.equals(v.getOrgId())) // Tenant isolation check
.map(v -> { variableRepository.delete(v); return ResponseEntity.ok().<Void>build(); })
.orElse(ResponseEntity.notFound().build());
}
}

View File

@ -0,0 +1,8 @@
package com.autopilot.localagent_cloud.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class RunRequest {
public RunResult result;
}

View File

@ -0,0 +1,28 @@
package com.autopilot.localagent_cloud.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
public class RunResult {
public String environmentId;
public String environmentName;
public String projectId;
public String applicationId;
public String applicationName;
public String platformId;
public String platformName;
public Integer browserTypeId;
public String browserTypeName;
public String groupId;
public String groupName;
public String referenceId;
public String serviceUrl;
public String request_handler;
public String iterationval;
public List<Map<String, List<TestCaseIteration>>> testCase;
// Mutated status
public Integer result_status;
}

View File

@ -0,0 +1,13 @@
package com.autopilot.localagent_cloud.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class TestCaseIteration {
public List<TestStep> testSteps;
// Mutated status
public Integer executed_status;
public Integer result_status;
}

View File

@ -0,0 +1,20 @@
package com.autopilot.localagent_cloud.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class TestStep {
public String step_result_id;
public String actionName;
public String locatorName;
public String objectDetail;
public String data;
public String screenShot;
public String screenshot_path;
public String stepDesc;
public String errorLog;
// Mutated status
public Integer executed_status;
public Integer result_status;
}

View File

@ -0,0 +1,25 @@
package com.autopilot.localagent_cloud.governance;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
/**
* Storage, Logging & Governance Layer
* Handles audit trails, decision logging, and compliance reporting.
*/
@RestController
@RequestMapping("/api/governance")
public class AuditController {
@GetMapping("/audit-logs")
public ResponseEntity<List<String>> getAuditLogs(@RequestParam(required = false) String buildId) {
// TODO: Fetch immutable execution logs and manual override decisions
return ResponseEntity.ok(Collections.singletonList("Initial audit log: Build pipeline created"));
}
}

View File

@ -0,0 +1,22 @@
package com.autopilot.localagent_cloud.ingestion;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Test Data Ingestion Layer
* Handles incoming webhooks and test scripts from CI/CD systems.
*/
@RestController
@RequestMapping("/api/ingestion")
public class TestIngestionController {
@PostMapping("/webhook")
public ResponseEntity<String> receiveWebhook(@RequestBody String payload) {
// TODO: Parse webhook payload from GitHub/Jenkins and enqueue for execution
return ResponseEntity.ok("Webhook received and queued for processing");
}
}

View File

@ -0,0 +1,95 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "agents")
public class Agent {
@Id
private String id;
@Column(name = "org_id")
private Long orgId;
@Column(nullable = false)
private String name;
private String os;
@Column(name = "agent_version")
private String agentVersion;
@Column(name = "last_seen_at", nullable = false)
private LocalDateTime lastSeenAt;
@Column(name = "capabilities_json", columnDefinition = "text")
private String capabilitiesJson;
@PrePersist
protected void onCreate() {
lastSeenAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
lastSeenAt = LocalDateTime.now();
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getOs() {
return os;
}
public void setOs(String os) {
this.os = os;
}
public String getAgentVersion() {
return agentVersion;
}
public void setAgentVersion(String agentVersion) {
this.agentVersion = agentVersion;
}
public LocalDateTime getLastSeenAt() {
return lastSeenAt;
}
public void setLastSeenAt(LocalDateTime lastSeenAt) {
this.lastSeenAt = lastSeenAt;
}
public String getCapabilitiesJson() {
return capabilitiesJson;
}
public void setCapabilitiesJson(String capabilitiesJson) {
this.capabilitiesJson = capabilitiesJson;
}
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
}

View File

@ -0,0 +1,33 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "agent_group_mappings")
public class AgentGroupMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "agent_id", nullable = false)
private String agentId;
@Column(name = "group_id", nullable = false)
private Long groupId;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAgentId() { return agentId; }
public void setAgentId(String agentId) { this.agentId = agentId; }
public Long getGroupId() { return groupId; }
public void setGroupId(Long groupId) { this.groupId = groupId; }
}

View File

@ -0,0 +1,20 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "agent_tokens")
public class AgentToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "org_id", nullable = false) private Long orgId;
@Column(nullable = false, unique = true) private String token;
@Column private String label;
@Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt;
@PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; } public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getToken() { return token; } public void setToken(String token) { this.token = token; }
public String getLabel() { return label; } public void setLabel(String label) { this.label = label; }
public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,57 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_keys")
public class ApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id", nullable = false)
private Long orgId;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String token;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "last_used_at")
private LocalDateTime lastUsedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getLastUsedAt() { return lastUsedAt; }
public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; }
}

View File

@ -0,0 +1,26 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "org_id", nullable = false) private Long orgId;
@Column(nullable = false, unique = true) private String email;
@Column(name = "password_hash", nullable = false) private String passwordHash;
@Column(name = "full_name") private String fullName;
@Column(nullable = false) private String role = "admin";
@Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt;
@Column(name = "requires_password_change") private boolean requiresPasswordChange = false;
@PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); }
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; } public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getEmail() { return email; } public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getFullName() { return fullName; } public void setFullName(String fullName) { this.fullName = fullName; }
public String getRole() { return role; } public void setRole(String role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public boolean isRequiresPasswordChange() { return requiresPasswordChange; } public void setRequiresPasswordChange(boolean requiresPasswordChange) { this.requiresPasswordChange = requiresPasswordChange; }
}

View File

@ -0,0 +1,80 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id", nullable = false)
private Long orgId;
@Column(name = "user_email")
private String userEmail;
@Column(nullable = false)
private String action;
@Column(name = "entity_type", nullable = false)
private String entityType;
@Column(name = "entity_id")
private String entityId;
@Column(length = 1000)
private String details;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public AuditLog() {}
public AuditLog(Long orgId, String userEmail, String action, String entityType, String entityId, String details) {
this.orgId = orgId;
this.userEmail = userEmail;
this.action = action;
this.entityType = entityType;
this.entityId = entityId;
this.details = details;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getUserEmail() { return userEmail; }
public void setUserEmail(String userEmail) { this.userEmail = userEmail; }
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public String getEntityType() { return entityType; }
public void setEntityType(String entityType) { this.entityType = entityType; }
public String getEntityId() { return entityId; }
public void setEntityId(String entityId) { this.entityId = entityId; }
public String getDetails() { return details; }
public void setDetails(String details) { this.details = details; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,65 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "datasets")
public class Dataset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "description")
private String description;
@Column(name = "headers", columnDefinition = "text")
private String headers;
@Column(name = "rows", columnDefinition = "text")
private String rows;
@Column(name = "row_count")
private Integer rowCount;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getHeaders() { return headers; }
public void setHeaders(String headers) { this.headers = headers; }
public String getRows() { return rows; }
public void setRows(String rows) { this.rows = rows; }
public Integer getRowCount() { return rowCount; }
public void setRowCount(Integer rowCount) { this.rowCount = rowCount; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}

View File

@ -0,0 +1,54 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "device_pairings")
public class DevicePairing {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "pairing_code", nullable = false, unique = true)
private String pairingCode;
@Column(nullable = false)
private String status = "PENDING"; // PENDING, PAIRED, EXPIRED
@Column(name = "agent_token")
private String agentToken;
@Column(name = "org_id")
private Long orgId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
if (this.expiresAt == null) {
this.expiresAt = this.createdAt.plusMinutes(15); // Valid for 15 minutes
}
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getPairingCode() { return pairingCode; }
public void setPairingCode(String pairingCode) { this.pairingCode = pairingCode; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getAgentToken() { return agentToken; }
public void setAgentToken(String agentToken) { this.agentToken = agentToken; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getExpiresAt() { return expiresAt; }
public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
}

View File

@ -0,0 +1,38 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "environments")
public class Environment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "description", columnDefinition = "text")
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() { this.createdAt = LocalDateTime.now(); }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDateTime getCreatedAt() { return createdAt; }
}

View File

@ -0,0 +1,124 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
@Entity
@Table(name = "executions")
public class Execution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(name = "org_execution_id")
private Long orgExecutionId;
@Column(name = "environment_id")
private Long environmentId;
@Column(name = "target_group_id")
private Long targetGroupId;
@Column(name = "browser_type")
private String browserType;
@Column(name = "browser_version")
private String browserVersion;
@Column(nullable = false)
private String status; // QUEUED, RUNNING, SUCCESS, FAILED
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "finished_at")
private LocalDateTime finishedAt;
@Column(name = "environment_json", columnDefinition = "text")
private String environmentJson;
@Column(name = "scheduler_id")
private Long schedulerId;
@Column(name = "ai_analysis", columnDefinition = "text")
private String aiAnalysis;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(LocalDateTime finishedAt) {
this.finishedAt = finishedAt;
}
public String getEnvironmentJson() {
return environmentJson;
}
public void setEnvironmentJson(String environmentJson) {
this.environmentJson = environmentJson;
}
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public Long getOrgExecutionId() { return orgExecutionId; }
public void setOrgExecutionId(Long orgExecutionId) { this.orgExecutionId = orgExecutionId; }
public Long getEnvironmentId() { return environmentId; }
public void setEnvironmentId(Long environmentId) { this.environmentId = environmentId; }
public Long getTargetGroupId() { return targetGroupId; }
public void setTargetGroupId(Long targetGroupId) { this.targetGroupId = targetGroupId; }
public String getBrowserType() { return browserType; }
public void setBrowserType(String browserType) { this.browserType = browserType; }
public String getBrowserVersion() { return browserVersion; }
public void setBrowserVersion(String browserVersion) { this.browserVersion = browserVersion; }
public Long getSchedulerId() { return schedulerId; }
public void setSchedulerId(Long schedulerId) { this.schedulerId = schedulerId; }
public String getAiAnalysis() { return aiAnalysis; }
public void setAiAnalysis(String aiAnalysis) { this.aiAnalysis = aiAnalysis; }
}

View File

@ -0,0 +1,71 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
@Entity
@Table(name = "groups")
public class Group {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(nullable = false)
private String name;
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
}

View File

@ -0,0 +1,125 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "jobs")
public class Job {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "execution_id")
private Long executionId;
@Column(name = "agent_id")
private String agentId;
@Column(name = "target_group_id")
private Long targetGroupId;
@Column(name = "test_case_id")
private Long testCaseId;
@Column(name = "browser_type")
private String browserType;
@Column(name = "browser_version")
private String browserVersion;
@Column(nullable = false)
private String status; // QUEUED, ASSIGNED, COMPLETED, TIMEOUT
@Column(name = "lease_expires_at")
private LocalDateTime leaseExpiresAt;
@Column(name = "payload_json", nullable = false, columnDefinition = "text")
private String payloadJson;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getExecutionId() {
return executionId;
}
public void setExecutionId(Long executionId) {
this.executionId = executionId;
}
public String getAgentId() {
return agentId;
}
public void setAgentId(String agentId) {
this.agentId = agentId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getLeaseExpiresAt() {
return leaseExpiresAt;
}
public void setLeaseExpiresAt(LocalDateTime leaseExpiresAt) {
this.leaseExpiresAt = leaseExpiresAt;
}
public String getPayloadJson() {
return payloadJson;
}
public void setPayloadJson(String payloadJson) {
this.payloadJson = payloadJson;
}
public String getBrowserType() {
return browserType;
}
public void setBrowserType(String browserType) {
this.browserType = browserType;
}
public String getBrowserVersion() {
return browserVersion;
}
public void setBrowserVersion(String browserVersion) {
this.browserVersion = browserVersion;
}
public Long getTargetGroupId() {
return targetGroupId;
}
public void setTargetGroupId(Long targetGroupId) {
this.targetGroupId = targetGroupId;
}
public Long getTestCaseId() {
return testCaseId;
}
public void setTestCaseId(Long testCaseId) {
this.testCaseId = testCaseId;
}
}

View File

@ -0,0 +1,24 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "organisations")
public class Organisation {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private String name;
@Column(nullable = false) private String plan = "trial";
@Column(unique = true) private String subdomain;
@Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt;
@PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); }
@Column(name = "public_id", unique = true) private String publicId;
// getters/setters for id, name, plan, createdAt, subdomain, publicId
public Long getId() { return id; } public void setId(Long id) { this.id = id; }
public String getName() { return name; } public void setName(String name) { this.name = name; }
public String getPlan() { return plan; } public void setPlan(String plan) { this.plan = plan; }
public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public String getSubdomain() { return subdomain; } public void setSubdomain(String subdomain) { this.subdomain = subdomain; }
public String getPublicId() { return publicId; } public void setPublicId(String publicId) { this.publicId = publicId; }
}

View File

@ -0,0 +1,211 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
@Entity
@Table(name = "schedulers")
public class Scheduler {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(name = "test_suite_name", nullable = false)
private String testSuiteName;
@Column(name = "execution_type", nullable = false)
private String executionType;
@Column(name = "cron_expression")
private String cronExpression;
@Column(name = "browser_type", nullable = false)
private String browserType;
@Column(nullable = false)
private String status;
@Column(name = "test_suite_id")
private Long testSuiteId;
@Column(name = "environment_id")
private Long environmentId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "target_group_id")
private Long targetGroupId;
@Column(name = "browser_version")
private String browserVersion;
// âââ Outlook-style scheduling fields ââââââââââââââââââââââââââââââââââââââ
/** The specific date to run (used for 'once' and as the start date for recurring) */
@Column(name = "scheduled_date")
private LocalDate scheduledDate;
/** The time of day to run */
@Column(name = "scheduled_time")
private LocalTime scheduledTime;
/** Recurrence pattern: 'once', 'daily', 'weekly', 'monthly' */
@Column(name = "recurrence_type")
private String recurrenceType;
/** Comma-separated day abbreviations for weekly recurrence, e.g. "MON,WED,FRI" */
@Column(name = "recurrence_days")
private String recurrenceDays;
/** Optional end date for recurring schedules */
@Column(name = "recurrence_end_date")
private LocalDate recurrenceEndDate;
@Column(name = "timezone")
private String timezone;
//
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTestSuiteName() {
return testSuiteName;
}
public void setTestSuiteName(String testSuiteName) {
this.testSuiteName = testSuiteName;
}
public String getExecutionType() {
return executionType;
}
public void setExecutionType(String executionType) {
this.executionType = executionType;
}
public String getCronExpression() {
return cronExpression;
}
public void setCronExpression(String cronExpression) {
this.cronExpression = cronExpression;
}
public String getBrowserType() {
return browserType;
}
public void setBrowserType(String browserType) {
this.browserType = browserType;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Long getTestSuiteId() {
return testSuiteId;
}
public void setTestSuiteId(Long testSuiteId) {
this.testSuiteId = testSuiteId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDate getScheduledDate() {
return scheduledDate;
}
public void setScheduledDate(LocalDate scheduledDate) {
this.scheduledDate = scheduledDate;
}
public LocalTime getScheduledTime() {
return scheduledTime;
}
public void setScheduledTime(LocalTime scheduledTime) {
this.scheduledTime = scheduledTime;
}
public String getRecurrenceType() {
return recurrenceType;
}
public void setRecurrenceType(String recurrenceType) {
this.recurrenceType = recurrenceType;
}
public String getRecurrenceDays() {
return recurrenceDays;
}
public void setRecurrenceDays(String recurrenceDays) {
this.recurrenceDays = recurrenceDays;
}
public LocalDate getRecurrenceEndDate() {
return recurrenceEndDate;
}
public void setRecurrenceEndDate(LocalDate recurrenceEndDate) {
this.recurrenceEndDate = recurrenceEndDate;
}
public String getTimezone() {
return timezone;
}
public void setTimezone(String timezone) {
this.timezone = timezone;
}
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public Long getEnvironmentId() { return environmentId; }
public void setEnvironmentId(Long environmentId) { this.environmentId = environmentId; }
public Long getTargetGroupId() { return targetGroupId; }
public void setTargetGroupId(Long targetGroupId) { this.targetGroupId = targetGroupId; }
public String getBrowserVersion() { return browserVersion; }
public void setBrowserVersion(String browserVersion) { this.browserVersion = browserVersion; }
}

View File

@ -0,0 +1,80 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "screenshots")
public class Screenshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "execution_id")
private Long executionId;
@Column(name = "step_result_id")
private Long stepResultId;
@Column(name = "file_name", nullable = false)
private String fileName;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "storage_path", nullable = false)
private String storagePath;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getExecutionId() {
return executionId;
}
public void setExecutionId(Long executionId) {
this.executionId = executionId;
}
public Long getStepResultId() {
return stepResultId;
}
public void setStepResultId(Long stepResultId) {
this.stepResultId = stepResultId;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getStoragePath() {
return storagePath;
}
public void setStoragePath(String storagePath) {
this.storagePath = storagePath;
}
}

View File

@ -0,0 +1,113 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "step_results")
public class StepResult {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "execution_id")
private Long executionId;
@Column(name = "step_index", nullable = false)
private Integer stepIndex;
@Column(name = "action_name", nullable = false)
private String actionName;
@Column(name = "executed_status", nullable = false)
private Integer executedStatus;
@Column(name = "result_status", nullable = false)
private Integer resultStatus;
@Column(name = "error_json", columnDefinition = "text")
private String errorJson;
@Column(name = "actual_value", columnDefinition = "text")
private String actualValue;
@Column(name = "step_type")
private String stepType = "ACTION";
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getExecutionId() {
return executionId;
}
public void setExecutionId(Long executionId) {
this.executionId = executionId;
}
public Integer getStepIndex() {
return stepIndex;
}
public void setStepIndex(Integer stepIndex) {
this.stepIndex = stepIndex;
}
public String getActionName() {
return actionName;
}
public void setActionName(String actionName) {
this.actionName = actionName;
}
public Integer getExecutedStatus() {
return executedStatus;
}
public void setExecutedStatus(Integer executedStatus) {
this.executedStatus = executedStatus;
}
public Integer getResultStatus() {
return resultStatus;
}
public void setResultStatus(Integer resultStatus) {
this.resultStatus = resultStatus;
}
public String getErrorJson() {
return errorJson;
}
public void setErrorJson(String errorJson) {
this.errorJson = errorJson;
}
public String getActualValue() {
return actualValue;
}
public void setActualValue(String actualValue) {
this.actualValue = actualValue;
}
public String getStepType() {
return stepType;
}
public void setStepType(String stepType) {
this.stepType = stepType;
}
}

View File

@ -0,0 +1,76 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_cases")
public class TestCase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String status = "active";
@Column(name = "is_component")
private Boolean isComponent = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public Boolean getIsComponent() { return isComponent; }
public void setIsComponent(Boolean isComponent) { this.isComponent = isComponent; }
}

View File

@ -0,0 +1,70 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_case_groups")
public class TestCaseGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String status = "active";
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
}

View File

@ -0,0 +1,39 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_case_group_mappings")
public class TestCaseGroupMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "test_case_group_id", nullable = false)
private Long testCaseGroupId;
@Column(name = "test_case_id", nullable = false)
private Long testCaseId;
@Column(name = "case_order", nullable = false)
private Integer caseOrder = 0;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getTestCaseGroupId() { return testCaseGroupId; }
public void setTestCaseGroupId(Long testCaseGroupId) { this.testCaseGroupId = testCaseGroupId; }
public Long getTestCaseId() { return testCaseId; }
public void setTestCaseId(Long testCaseId) { this.testCaseId = testCaseId; }
public Integer getCaseOrder() { return caseOrder; }
public void setCaseOrder(Integer caseOrder) { this.caseOrder = caseOrder; }
}

View File

@ -0,0 +1,87 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_steps")
public class TestStep {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "test_case_id", nullable = false)
private Long testCaseId;
@Column(name = "step_order", nullable = false)
private Integer stepOrder;
@Column(name = "action_name", nullable = false)
private String actionName;
@Column(name = "locator_type")
private String locatorType;
@Column(name = "locator_value")
private String locatorValue;
@Column(name = "test_data")
private String testData;
@Column(name = "step_type", nullable = false)
private String stepType = "ACTION"; // ACTION or VERIFY
@Column(name = "expected_value", columnDefinition = "text")
private String expectedValue;
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getTestCaseId() { return testCaseId; }
public void setTestCaseId(Long testCaseId) { this.testCaseId = testCaseId; }
public Integer getStepOrder() { return stepOrder; }
public void setStepOrder(Integer stepOrder) { this.stepOrder = stepOrder; }
public String getActionName() { return actionName; }
public void setActionName(String actionName) { this.actionName = actionName; }
public String getLocatorType() { return locatorType; }
public void setLocatorType(String locatorType) { this.locatorType = locatorType; }
public String getLocatorValue() { return locatorValue; }
public void setLocatorValue(String locatorValue) { this.locatorValue = locatorValue; }
public String getTestData() { return testData; }
public void setTestData(String testData) { this.testData = testData; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getStepType() { return stepType; }
public void setStepType(String stepType) { this.stepType = stepType; }
public String getExpectedValue() { return expectedValue; }
public void setExpectedValue(String expectedValue) { this.expectedValue = expectedValue; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,76 @@
package com.autopilot.localagent_cloud.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_suites")
public class TestSuite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(nullable = false)
private String name;
private String description;
@Column(name = "browser_type", nullable = false)
private String browserType = "chrome";
@Column(nullable = false)
private String status = "active";
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getBrowserType() { return browserType; }
public void setBrowserType(String browserType) { this.browserType = browserType; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
}

View File

@ -0,0 +1,39 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "test_suite_group_mappings")
public class TestSuiteGroupMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "test_suite_id", nullable = false)
private Long testSuiteId;
@Column(name = "test_case_group_id", nullable = false)
private Long testCaseGroupId;
@Column(name = "group_order", nullable = false)
private Integer groupOrder = 0;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getTestSuiteId() { return testSuiteId; }
public void setTestSuiteId(Long testSuiteId) { this.testSuiteId = testSuiteId; }
public Long getTestCaseGroupId() { return testCaseGroupId; }
public void setTestCaseGroupId(Long testCaseGroupId) { this.testCaseGroupId = testCaseGroupId; }
public Integer getGroupOrder() { return groupOrder; }
public void setGroupOrder(Integer groupOrder) { this.groupOrder = groupOrder; }
}

View File

@ -0,0 +1,53 @@
package com.autopilot.localagent_cloud.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "variables")
public class Variable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "org_id")
private Long orgId;
@Column(name = "scope", nullable = false)
private String scope = "GLOBAL"; // GLOBAL, SUITE, ENVIRONMENT
@Column(name = "scope_id")
private Long scopeId;
@Column(name = "key_name", nullable = false)
private String keyName;
@Column(name = "value", columnDefinition = "text")
private String value;
@Column(name = "is_secret")
private Boolean isSecret = false;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() { this.createdAt = LocalDateTime.now(); }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
public Long getScopeId() { return scopeId; }
public void setScopeId(Long scopeId) { this.scopeId = scopeId; }
public String getKeyName() { return keyName; }
public void setKeyName(String keyName) { this.keyName = keyName; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public Boolean getIsSecret() { return isSecret; }
public void setIsSecret(Boolean isSecret) { this.isSecret = isSecret; }
public LocalDateTime getCreatedAt() { return createdAt; }
}

View File

@ -0,0 +1,16 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.AgentGroupMapping;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface AgentGroupMappingRepository extends JpaRepository<AgentGroupMapping, Long> {
List<AgentGroupMapping> findByGroupId(Long groupId);
List<AgentGroupMapping> findByAgentId(String agentId);
@Transactional
void deleteByAgentIdAndGroupId(String agentId, Long groupId);
}

View File

@ -0,0 +1,12 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Agent;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AgentRepository extends JpaRepository<Agent, String> {
List<Agent> findByOrgId(Long orgId);
List<Agent> findAll();
}

View File

@ -0,0 +1,12 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.AgentToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AgentTokenRepository extends JpaRepository<AgentToken, Long> {
Optional<AgentToken> findByToken(String token);
java.util.List<AgentToken> findByOrgId(Long orgId);
}

View File

@ -0,0 +1,14 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.ApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
List<ApiKey> findByOrgId(Long orgId);
Optional<ApiKey> findByToken(String token);
}

View File

@ -0,0 +1,13 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByEmail(String email);
boolean existsByEmail(String email);
java.util.List<AppUser> findByOrgId(Long orgId);
}

View File

@ -0,0 +1,9 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findByOrgIdOrderByCreatedAtDesc(Long orgId);
}

View File

@ -0,0 +1,9 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Dataset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface DatasetRepository extends JpaRepository<Dataset, Long> {
List<Dataset> findByOrgId(Long orgId);
}

View File

@ -0,0 +1,12 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.DevicePairing;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface DevicePairingRepository extends JpaRepository<DevicePairing, Long> {
Optional<DevicePairing> findByPairingCode(String pairingCode);
}

View File

@ -0,0 +1,9 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Environment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface EnvironmentRepository extends JpaRepository<Environment, Long> {
List<Environment> findByOrgId(Long orgId);
}

View File

@ -0,0 +1,15 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Execution;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ExecutionRepository extends JpaRepository<Execution, Long> {
List<Execution> findAllByOrderByIdDesc();
long countByOrgId(Long orgId);
List<Execution> findByStatusAndCreatedAtBefore(String status, java.time.LocalDateTime cutoff);
Optional<Execution> findFirstBySchedulerIdOrderByIdDesc(Long schedulerId);
}

View File

@ -0,0 +1,9 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Group;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface GroupRepository extends JpaRepository<Group, Long> {
}

View File

@ -0,0 +1,21 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Job;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface JobRepository extends JpaRepository<Job, Long> {
@Query("SELECT j FROM Job j WHERE j.status = 'QUEUED' " +
"AND (j.agentId = :agentId OR j.agentId IS NULL) " +
"AND (j.targetGroupId IS NULL OR j.targetGroupId IN :groupIDs) " +
"ORDER BY j.id ASC")
List<Job> findNextAvailableJobs(@Param("agentId") String agentId, @Param("groupIDs") List<Long> groupIDs, Pageable pageable);
List<Job> findByExecutionId(Long executionId);
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Organisation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface OrganisationRepository extends JpaRepository<Organisation, Long> {
Organisation findBySubdomain(String subdomain);
Organisation findByPublicId(String publicId);
}

View File

@ -0,0 +1,9 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Scheduler;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SchedulerRepository extends JpaRepository<Scheduler, Long> {
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Screenshot;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ScreenshotRepository extends JpaRepository<Screenshot, Long> {
List<Screenshot> findByExecutionId(Long executionId);
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.StepResult;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface StepResultRepository extends JpaRepository<StepResult, Long> {
List<StepResult> findByExecutionId(Long executionId);
}

View File

@ -0,0 +1,15 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestCaseGroupMapping;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface TestCaseGroupMappingRepository extends JpaRepository<TestCaseGroupMapping, Long> {
List<TestCaseGroupMapping> findByTestCaseGroupIdOrderByCaseOrder(Long testCaseGroupId);
@Transactional
void deleteByTestCaseGroupId(Long testCaseGroupId);
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestCaseGroup;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TestCaseGroupRepository extends JpaRepository<TestCaseGroup, Long> {
List<TestCaseGroup> findAllByOrderByIdDesc();
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestCase;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TestCaseRepository extends JpaRepository<TestCase, Long> {
List<TestCase> findAllByOrderByIdDesc();
}

View File

@ -0,0 +1,15 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestStep;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface TestStepRepository extends JpaRepository<TestStep, Long> {
List<TestStep> findByTestCaseIdOrderByStepOrder(Long testCaseId);
@Transactional
void deleteByTestCaseId(Long testCaseId);
}

View File

@ -0,0 +1,15 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestSuiteGroupMapping;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface TestSuiteGroupMappingRepository extends JpaRepository<TestSuiteGroupMapping, Long> {
List<TestSuiteGroupMapping> findByTestSuiteIdOrderByGroupOrder(Long testSuiteId);
@Transactional
void deleteByTestSuiteId(Long testSuiteId);
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.TestSuite;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TestSuiteRepository extends JpaRepository<TestSuite, Long> {
List<TestSuite> findAllByOrderByIdDesc();
}

View File

@ -0,0 +1,11 @@
package com.autopilot.localagent_cloud.repository;
import com.autopilot.localagent_cloud.model.Variable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface VariableRepository extends JpaRepository<Variable, Long> {
List<Variable> findByOrgId(Long orgId);
List<Variable> findByOrgIdAndScope(Long orgId, String scope);
List<Variable> findByOrgIdAndScopeAndScopeId(Long orgId, String scope, Long scopeId);
}

View File

@ -0,0 +1,508 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Agent;
import com.autopilot.localagent_cloud.model.AgentToken;
import com.autopilot.localagent_cloud.model.Execution;
import com.autopilot.localagent_cloud.model.Scheduler;
import com.autopilot.localagent_cloud.model.Screenshot;
import com.autopilot.localagent_cloud.model.StepResult;
import com.autopilot.localagent_cloud.model.TestCaseGroupMapping;
import com.autopilot.localagent_cloud.model.TestStep;
import com.autopilot.localagent_cloud.model.TestSuite;
import com.autopilot.localagent_cloud.model.TestSuiteGroupMapping;
import com.autopilot.localagent_cloud.repository.AgentRepository;
import com.autopilot.localagent_cloud.repository.AgentTokenRepository;
import com.autopilot.localagent_cloud.repository.ExecutionRepository;
import com.autopilot.localagent_cloud.repository.SchedulerRepository;
import com.autopilot.localagent_cloud.repository.ScreenshotRepository;
import com.autopilot.localagent_cloud.repository.StepResultRepository;
import com.autopilot.localagent_cloud.repository.TestCaseGroupMappingRepository;
import com.autopilot.localagent_cloud.repository.TestStepRepository;
import com.autopilot.localagent_cloud.repository.TestSuiteGroupMappingRepository;
import com.autopilot.localagent_cloud.repository.TestSuiteRepository;
import com.autopilot.localagent_cloud.repository.VariableRepository;
import com.autopilot.localagent_cloud.repository.OrganisationRepository;
import com.autopilot.localagent_cloud.repository.AgentGroupMappingRepository;
import com.autopilot.localagent_cloud.repository.JobRepository;
import com.autopilot.localagent_cloud.model.Organisation;
import com.autopilot.localagent_cloud.model.Variable;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class AgentService {
private final AgentRepository agentRepository;
private final AgentTokenRepository agentTokenRepository;
private final SchedulerRepository schedulerRepository;
private final ExecutionRepository executionRepository;
private final TestSuiteRepository testSuiteRepository;
private final TestSuiteGroupMappingRepository testSuiteGroupMappingRepository;
private final TestCaseGroupMappingRepository testCaseGroupMappingRepository;
private final TestStepRepository testStepRepository;
private final ScreenshotRepository screenshotRepository;
private final StepResultRepository stepResultRepository;
private final OrganisationRepository organisationRepository;
private final VariableRepository variableRepository;
private final AgentGroupMappingRepository agentGroupMappingRepository;
private final JobRepository jobRepository;
private final ObjectMapper objectMapper;
private final S3Service s3Service;
public AgentService(AgentRepository agentRepository,
AgentTokenRepository agentTokenRepository,
SchedulerRepository schedulerRepository,
ExecutionRepository executionRepository,
TestSuiteRepository testSuiteRepository,
TestSuiteGroupMappingRepository testSuiteGroupMappingRepository,
TestCaseGroupMappingRepository testCaseGroupMappingRepository,
TestStepRepository testStepRepository,
ScreenshotRepository screenshotRepository,
StepResultRepository stepResultRepository,
OrganisationRepository organisationRepository,
VariableRepository variableRepository,
AgentGroupMappingRepository agentGroupMappingRepository,
JobRepository jobRepository,
ObjectMapper objectMapper,
S3Service s3Service) {
this.agentRepository = agentRepository;
this.agentTokenRepository = agentTokenRepository;
this.schedulerRepository = schedulerRepository;
this.executionRepository = executionRepository;
this.testSuiteRepository = testSuiteRepository;
this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository;
this.testCaseGroupMappingRepository = testCaseGroupMappingRepository;
this.testStepRepository = testStepRepository;
this.screenshotRepository = screenshotRepository;
this.stepResultRepository = stepResultRepository;
this.organisationRepository = organisationRepository;
this.variableRepository = variableRepository;
this.agentGroupMappingRepository = agentGroupMappingRepository;
this.jobRepository = jobRepository;
this.objectMapper = objectMapper;
this.s3Service = s3Service;
}
public ResponseEntity<List<Agent>> getAll(Long orgId) {
List<Agent> list = orgId != null
? agentRepository.findAll().stream().filter(a -> orgId.equals(a.getOrgId())).toList()
: agentRepository.findAll();
return ResponseEntity.ok(list);
}
@Transactional
public ResponseEntity<Map<String, Object>> register(Map<String, Object> body) {
String agentId = (String) body.get("id");
String agentTokenStr = (String) body.get("agentToken");
if (agentId == null || agentId.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Missing agent id"));
}
if (agentTokenStr == null || agentTokenStr.isBlank()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Missing agentToken"));
}
AgentToken token = agentTokenRepository.findByToken(agentTokenStr).orElse(null);
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Invalid agentToken"));
}
Agent agent = agentRepository.findById(agentId).orElse(new Agent());
agent.setId(agentId);
agent.setOrgId(token.getOrgId());
agent.setName((String) body.getOrDefault("name", agentId));
agent.setOs((String) body.get("os"));
agent.setAgentVersion((String) body.get("agentVersion"));
agent.setCapabilitiesJson((String) body.get("capabilitiesJson"));
agent.setLastSeenAt(java.time.LocalDateTime.now());
agentRepository.save(agent);
String orgName = "Unknown Organisation";
if (token.getOrgId() != null) {
Organisation org = organisationRepository.findById(token.getOrgId()).orElse(null);
if (org != null) {
orgName = org.getName();
}
}
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("orgName", orgName);
response.put("tokenLabel", token.getLabel());
return ResponseEntity.ok(response);
}
@Transactional
public ResponseEntity<Void> heartbeat(String id) {
agentRepository.findById(id).ifPresent(agent -> {
agent.setLastSeenAt(java.time.LocalDateTime.now());
agentRepository.save(agent);
});
return ResponseEntity.ok().build();
}
@Transactional
@SuppressWarnings("unchecked")
public ResponseEntity<Map<String, Object>> getNextJob(String agentId) {
Agent agent = agentRepository.findById(agentId).orElse(null);
if (agent == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
agent.setLastSeenAt(java.time.LocalDateTime.now());
agentRepository.save(agent);
Long agentOrgId = agent.getOrgId();
List<Long> agentGroupIds = agentGroupMappingRepository.findByAgentId(agentId).stream()
.map(com.autopilot.localagent_cloud.model.AgentGroupMapping::getGroupId)
.toList();
List<Long> groupIDsForQuery = agentGroupIds.isEmpty() ? List.of(-1L) : agentGroupIds;
String agentBrowserVersion = "";
try {
if (agent.getCapabilitiesJson() != null) {
Map<String, Object> caps = objectMapper.readValue(agent.getCapabilitiesJson(), Map.class);
if (caps.containsKey("browserVersion")) {
agentBrowserVersion = (String) caps.get("browserVersion");
}
}
} catch (Exception e) {}
final String finalAgentBrowserVer = agentBrowserVersion;
// Phase 1: Try to pick up an existing QUEUED job
List<com.autopilot.localagent_cloud.model.Job> candidateJobs = jobRepository.findNextAvailableJobs(agentId, groupIDsForQuery, PageRequest.of(0, 50));
com.autopilot.localagent_cloud.model.Job selectedJob = candidateJobs.stream().findFirst().orElse(null);
if (selectedJob != null) {
selectedJob.setStatus("ASSIGNED");
selectedJob.setAgentId(agentId);
selectedJob.setLeaseExpiresAt(java.time.LocalDateTime.now().plusMinutes(10));
jobRepository.save(selectedJob);
try {
Map<String, Object> runRequest = objectMapper.readValue(selectedJob.getPayloadJson(), Map.class);
runRequest.put("jobId", selectedJob.getId()); // Inject jobId for the agent
Map<String, Object> jobDto = new HashMap<>();
jobDto.put("executionId", selectedJob.getExecutionId());
jobDto.put("jobId", selectedJob.getId());
jobDto.put("payloadJson", objectMapper.writeValueAsString(runRequest));
return ResponseEntity.ok(jobDto);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build();
}
}
// Phase 2: Unwrap a Scheduler into multiple QUEUED Jobs
List<Scheduler> activeJobs = schedulerRepository.findAll().stream()
.filter(s -> "now".equals(s.getExecutionType()) && "active".equals(s.getStatus()))
.filter(s -> agentOrgId == null || agentOrgId.equals(s.getOrgId()))
.toList();
if (activeJobs.isEmpty()) {
return ResponseEntity.noContent().build();
}
Scheduler job = activeJobs.get(0);
job.setStatus("processing");
schedulerRepository.save(job);
Execution execution = new Execution();
execution.setOrgId(agentOrgId);
long count = agentOrgId != null ? executionRepository.countByOrgId(agentOrgId) : 0;
execution.setOrgExecutionId(count + 1);
execution.setEnvironmentId(job.getEnvironmentId());
execution.setTargetGroupId(job.getTargetGroupId());
execution.setBrowserType(job.getBrowserType());
execution.setBrowserVersion(job.getBrowserVersion());
execution.setSchedulerId(job.getId());
execution.setEnvironmentJson("{\"referenceId\":\"" + job.getTestSuiteName() + "\",\"browserTypeName\":\"" + job.getBrowserType() + "\"}");
execution.setStatus("running");
execution.setCreatedAt(java.time.LocalDateTime.now());
execution = executionRepository.save(execution);
// Resolve Variables: GLOBAL and ENVIRONMENT
Map<String, String> executionVariables = new HashMap<>();
if (agentOrgId != null) {
List<Variable> globalVars = variableRepository.findByOrgIdAndScope(agentOrgId, "GLOBAL");
for (Variable v : globalVars) {
executionVariables.put(v.getKeyName(), v.getValue());
}
if (job.getEnvironmentId() != null) {
List<Variable> envVars = variableRepository.findByOrgIdAndScopeAndScopeId(agentOrgId, "ENVIRONMENT", job.getEnvironmentId());
for (Variable v : envVars) {
executionVariables.put(v.getKeyName(), v.getValue()); // overrides global
}
}
}
if (job.getTestSuiteId() != null) {
List<TestSuiteGroupMapping> groupMappings = testSuiteGroupMappingRepository.findByTestSuiteIdOrderByGroupOrder(job.getTestSuiteId());
for (TestSuiteGroupMapping gm : groupMappings) {
List<TestCaseGroupMapping> caseMappings = testCaseGroupMappingRepository.findByTestCaseGroupIdOrderByCaseOrder(gm.getTestCaseGroupId());
for (TestCaseGroupMapping cm : caseMappings) {
List<TestStep> rawSteps = testStepRepository.findByTestCaseIdOrderByStepOrder(cm.getTestCaseId());
List<Map<String, Object>> processedSteps = new ArrayList<>();
for (TestStep s : rawSteps) {
if ("CALL_COMPONENT".equals(s.getActionName())) {
try {
Long compId = Long.valueOf(s.getLocatorValue());
List<TestStep> compSteps = testStepRepository.findByTestCaseIdOrderByStepOrder(compId);
for (TestStep cs : compSteps) {
processedSteps.add(stepToMapAndReplace(cs, executionVariables));
}
} catch (Exception e) {}
} else {
processedSteps.add(stepToMapAndReplace(s, executionVariables));
}
}
Map<String, Object> iter = new HashMap<>();
iter.put("testCaseId", cm.getTestCaseId());
iter.put("steps", processedSteps);
Map<String, Object> runRequest = new HashMap<>();
runRequest.put("executionId", execution.getId());
runRequest.put("browserType", job.getBrowserType());
runRequest.put("iterations", List.of(iter)); // ONLY THIS TEST CASE!
runRequest.put("variables", executionVariables);
try {
String payloadJson = objectMapper.writeValueAsString(runRequest);
com.autopilot.localagent_cloud.model.Job newJob = new com.autopilot.localagent_cloud.model.Job();
newJob.setExecutionId(execution.getId());
newJob.setTestCaseId(cm.getTestCaseId());
newJob.setTargetGroupId(job.getTargetGroupId());
newJob.setBrowserType(job.getBrowserType());
newJob.setBrowserVersion(job.getBrowserVersion());
newJob.setStatus("QUEUED");
newJob.setPayloadJson(payloadJson);
jobRepository.save(newJob);
} catch (Exception e) {}
}
}
}
// Bug fix: replaced unbounded recursion with a simple retry pick up one of the jobs just created.
// The recursive approach had no depth limit and could cause a StackOverflowError
// if job creation failed unexpectedly.
List<com.autopilot.localagent_cloud.model.Job> retryJobs = jobRepository.findNextAvailableJobs(
agentId, groupIDsForQuery, PageRequest.of(0, 50));
com.autopilot.localagent_cloud.model.Job retryJob = retryJobs.stream().filter(j -> {
if (j.getBrowserVersion() == null || j.getBrowserVersion().isBlank()) return true;
if (finalAgentBrowserVer.isBlank()) return false;
String reqMajor = j.getBrowserVersion().split("\\.")[0];
String agentMajor = finalAgentBrowserVer.split("\\.")[0];
return reqMajor.equals(agentMajor);
}).findFirst().orElse(null);
if (retryJob != null) {
retryJob.setStatus("ASSIGNED");
retryJob.setAgentId(agentId);
retryJob.setLeaseExpiresAt(java.time.LocalDateTime.now().plusMinutes(10));
jobRepository.save(retryJob);
try {
Map<String, Object> runRequest = objectMapper.readValue(retryJob.getPayloadJson(), Map.class);
runRequest.put("jobId", retryJob.getId());
Map<String, Object> jobDto = new HashMap<>();
jobDto.put("executionId", retryJob.getExecutionId());
jobDto.put("jobId", retryJob.getId());
jobDto.put("payloadJson", objectMapper.writeValueAsString(runRequest));
return ResponseEntity.ok(jobDto);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
return ResponseEntity.noContent().build();
}
@Transactional
@SuppressWarnings("unchecked")
public ResponseEntity<Void> postResults(Long executionId, Map<String, Object> result) {
try {
Map<String, Object> runResult = result;
if (result.containsKey("result")) {
runResult = (Map<String, Object>) result.get("result");
}
// Correctly parse the overall result_status sent by the Java Agent
String finalStatus = "completed";
if (runResult.containsKey("result_status")) {
Object rs = runResult.get("result_status");
if (rs instanceof Number && ((Number) rs).intValue() == 0) {
finalStatus = "failed";
}
} else if (result.containsKey("status")) {
finalStatus = (String) result.get("status");
}
final String statusToSave = finalStatus;
Long jobId = null;
if (result.containsKey("jobId")) {
jobId = ((Number) result.get("jobId")).longValue();
}
if (jobId != null) {
jobRepository.findById(jobId).ifPresent(job -> {
job.setStatus(statusToSave);
jobRepository.save(job);
});
// Check if all jobs are completed
List<com.autopilot.localagent_cloud.model.Job> allJobs = jobRepository.findByExecutionId(executionId);
boolean allDone = allJobs.stream().allMatch(j -> "completed".equals(j.getStatus()) || "failed".equals(j.getStatus()));
boolean anyFailed = allJobs.stream().anyMatch(j -> "failed".equals(j.getStatus()));
if (allDone && !allJobs.isEmpty()) {
executionRepository.findById(executionId).ifPresent(exec -> {
exec.setStatus(anyFailed ? "failed" : "completed");
exec.setFinishedAt(java.time.LocalDateTime.now());
executionRepository.save(exec);
});
}
} else {
// Fallback if jobId is not provided
executionRepository.findById(executionId).ifPresent(exec -> {
exec.setStatus(statusToSave);
exec.setFinishedAt(java.time.LocalDateTime.now());
executionRepository.save(exec);
});
}
java.util.List<Map<String, Object>> testCaseList = (java.util.List<Map<String, Object>>) runResult.get("testCase");
if (testCaseList != null && !testCaseList.isEmpty()) {
Map<String, Object> firstIterationMap = testCaseList.get(0);
java.util.List<Map<String, Object>> iterations = (java.util.List<Map<String, Object>>) firstIterationMap.get("iteration1");
if (iterations != null && !iterations.isEmpty()) {
java.util.List<Map<String, Object>> testSteps = (java.util.List<Map<String, Object>>) iterations.get(0).get("testSteps");
if (testSteps != null) {
for (int i = 0; i < testSteps.size(); i++) {
Map<String, Object> stepData = testSteps.get(i);
StepResult sr = new StepResult();
sr.setExecutionId(executionId);
sr.setStepIndex(i + 1);
sr.setActionName((String) stepData.getOrDefault("actionName", "unknown"));
sr.setStepType((String) stepData.getOrDefault("stepType", "ACTION"));
Object execStatus = stepData.get("executed_status");
sr.setExecutedStatus(execStatus != null ? (Integer) execStatus : 0);
Object resStatus = stepData.get("result_status");
sr.setResultStatus(resStatus != null ? (Integer) resStatus : 0);
sr.setErrorJson((String) stepData.getOrDefault("errorLog", ""));
sr.setActualValue((String) stepData.get("actualValue"));
sr = stepResultRepository.save(sr);
String base64 = (String) stepData.get("screenshotBase64");
if (base64 != null && !base64.isEmpty()) {
byte[] imageBytes = java.util.Base64.getDecoder().decode(base64);
String fileName = "exec_" + executionId + "_step_" + sr.getId() + "_" + System.currentTimeMillis() + ".png";
String s3Url = s3Service.uploadImage(fileName, imageBytes);
Screenshot sc = new Screenshot();
sc.setExecutionId(executionId);
sc.setStepResultId(sr.getId());
sc.setFileName(fileName);
sc.setContentType("image/png");
sc.setStoragePath(s3Url);
screenshotRepository.save(sc);
}
}
}
}
}
} catch (Exception ex) {
// Bug fix: was silently swallowing all exceptions and returning 200 OK even when data was lost.
// Now logs the error properly and returns 500 so the agent knows it must retry.
org.slf4j.LoggerFactory.getLogger(AgentService.class)
.error("Critical error processing results for executionId={}: {}", executionId, ex.getMessage(), ex);
return ResponseEntity.status(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok().build();
}
@Transactional
public ResponseEntity<Void> stopExecution(Long executionId) {
executionRepository.findById(executionId).ifPresent(exec -> {
if ("running".equals(exec.getStatus()) || "queued".equals(exec.getStatus())) {
exec.setStatus("aborted");
exec.setFinishedAt(java.time.LocalDateTime.now());
executionRepository.save(exec);
}
});
return ResponseEntity.ok().build();
}
@Transactional
public ResponseEntity<Void> rerunExecution(Long executionId) {
executionRepository.findById(executionId).ifPresent(exec -> {
try {
com.fasterxml.jackson.databind.JsonNode env = objectMapper.readTree(exec.getEnvironmentJson());
String suiteName = env.path("referenceId").asText();
String browser = env.path("browserTypeName").asText("chrome");
TestSuite suite = testSuiteRepository.findAll().stream()
.filter(s -> s.getName().equals(suiteName))
.findFirst().orElse(null);
Scheduler scheduler = new Scheduler();
scheduler.setTestSuiteName(suiteName);
if (suite != null) {
scheduler.setTestSuiteId(suite.getId());
}
scheduler.setExecutionType("now");
scheduler.setBrowserType(browser);
scheduler.setStatus("active");
scheduler.setOrgId(exec.getOrgId());
schedulerRepository.save(scheduler);
} catch (Exception e) {
e.printStackTrace();
}
});
return ResponseEntity.ok().build();
}
private Map<String, Object> stepToMapAndReplace(TestStep step, Map<String, String> vars) {
Map<String, Object> map = new HashMap<>();
map.put("id", step.getId());
map.put("stepOrder", step.getStepOrder());
map.put("actionName", step.getActionName());
map.put("stepType", step.getStepType());
map.put("locatorType", step.getLocatorType());
map.put("locatorValue", replaceVars(step.getLocatorValue(), vars));
map.put("testData", replaceVars(step.getTestData(), vars));
map.put("expectedValue", replaceVars(step.getExpectedValue(), vars));
map.put("description", step.getDescription());
return map;
}
private String replaceVars(String text, Map<String, String> vars) {
if (text == null || vars == null || vars.isEmpty()) return text;
String result = text;
for (Map.Entry<String, String> entry : vars.entrySet()) {
if (entry.getValue() != null) {
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
}
return result;
}
}

View File

@ -0,0 +1,393 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Agent;
import com.autopilot.localagent_cloud.model.Execution;
import com.autopilot.localagent_cloud.model.Job;
import com.autopilot.localagent_cloud.model.Scheduler;
import com.autopilot.localagent_cloud.model.StepResult;
import com.autopilot.localagent_cloud.repository.AgentRepository;
import com.autopilot.localagent_cloud.repository.ExecutionRepository;
import com.autopilot.localagent_cloud.repository.JobRepository;
import com.autopilot.localagent_cloud.repository.SchedulerRepository;
import com.autopilot.localagent_cloud.repository.StepResultRepository;
import com.autopilot.localagent_cloud.repository.TestSuiteRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class AnalyticsService {
private final ExecutionRepository executionRepository;
private final SchedulerRepository schedulerRepository;
private final StepResultRepository stepResultRepository;
private final AgentRepository agentRepository;
private final JobRepository jobRepository;
private final TestSuiteRepository testSuiteRepository;
public AnalyticsService(ExecutionRepository executionRepository,
SchedulerRepository schedulerRepository,
StepResultRepository stepResultRepository,
AgentRepository agentRepository,
JobRepository jobRepository,
TestSuiteRepository testSuiteRepository) {
this.executionRepository = executionRepository;
this.schedulerRepository = schedulerRepository;
this.stepResultRepository = stepResultRepository;
this.agentRepository = agentRepository;
this.jobRepository = jobRepository;
this.testSuiteRepository = testSuiteRepository;
}
/**
* Calculates the "Top Flaky Test Suites" for an org.
*
* Flakiness Score is defined as:
* (number of runs that were NOT all-pass AND not-all-fail) / total runs * 100
* A suite must have at least 3 runs to be considered.
*
* Returns top 5 flaky suites sorted by flakiness score descending.
*/
public List<Map<String, Object>> getFlakySuites(Long orgId, int limit) {
// 1. Get all executions for the org
List<Execution> allExecs = executionRepository.findAllByOrderByIdDesc()
.stream()
.filter(e -> orgId == null || orgId.equals(e.getOrgId()))
.filter(e -> e.getSchedulerId() != null)
.collect(Collectors.toList());
// 2. Build a map of schedulerId -> Scheduler (for suite name)
Map<Long, String> schedulerNames = new HashMap<>();
Map<Long, Long> schedulerSuiteIds = new HashMap<>();
schedulerRepository.findAll().forEach(s -> {
schedulerNames.put(s.getId(), s.getTestSuiteName());
schedulerSuiteIds.put(s.getId(), s.getTestSuiteId());
});
Set<Long> existingSuiteIds = testSuiteRepository.findAll().stream().map(s -> s.getId()).collect(Collectors.toSet());
// 3. Group executions by testSuiteName via schedulerId
Map<String, List<Execution>> bySuite = allExecs.stream()
.filter(e -> {
Long suiteId = schedulerSuiteIds.get(e.getSchedulerId());
return suiteId != null && existingSuiteIds.contains(suiteId);
})
.collect(Collectors.groupingBy(e -> {
String name = schedulerNames.get(e.getSchedulerId());
return name != null ? name : "Suite #" + e.getSchedulerId();
}));
// 4. For each suite, calculate flakiness
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, List<Execution>> entry : bySuite.entrySet()) {
String suiteName = entry.getKey();
List<Execution> runs = entry.getValue();
// Need at least 2 runs to determine flakiness
if (runs.size() < 2) continue;
int totalRuns = runs.size();
int passedRuns = 0;
int failedRuns = 0;
int partialRuns = 0; // Mixed pass/fail steps = flaky
for (Execution exec : runs) {
String status = exec.getStatus();
if (status == null) continue;
if (status.equalsIgnoreCase("success") || status.equalsIgnoreCase("completed")) {
passedRuns++;
} else if (status.equalsIgnoreCase("failed")) {
failedRuns++;
}
}
// Flakiness: suite that doesn't consistently pass or fail
// High flakiness = many alternating results
int notConsistent = totalRuns - Math.max(passedRuns, failedRuns);
// Score from 0-100
double flakinessScore = totalRuns > 0 ? ((double) Math.min(passedRuns, failedRuns) / totalRuns) * 100.0 : 0;
if (flakinessScore == 0 && (passedRuns == 0 || failedRuns == 0)) {
// Consistent suite - not flaky
continue;
}
Map<String, Object> suiteData = new LinkedHashMap<>();
suiteData.put("suiteName", suiteName);
suiteData.put("totalRuns", totalRuns);
suiteData.put("passedRuns", passedRuns);
suiteData.put("failedRuns", failedRuns);
suiteData.put("flakinessScore", Math.round(flakinessScore * 10.0) / 10.0);
suiteData.put("trend", failedRuns > passedRuns ? "deteriorating" : "improving");
result.add(suiteData);
}
// Sort by flakiness score descending, return top N
result.sort((a, b) -> Double.compare(
(double) b.get("flakinessScore"),
(double) a.get("flakinessScore")
));
return result.stream().limit(limit).collect(Collectors.toList());
}
/**
* Returns per-suite performance stats for the leaderboard:
* suiteName, totalRuns, passedRuns, failedRuns, successRate, avgDurationSecs, lastRunAt
* Sorted by successRate descending (best performers first).
*/
public List<Map<String, Object>> getSuitePerformance(Long orgId, int limit) {
// Get all terminal executions (not QUEUED/RUNNING) linked to a scheduler
List<Execution> allExecs = executionRepository.findAllByOrderByIdDesc()
.stream()
.filter(e -> orgId == null || orgId.equals(e.getOrgId()))
.filter(e -> e.getSchedulerId() != null)
.filter(e -> {
String s = e.getStatus();
return s != null && !s.equalsIgnoreCase("queued") && !s.equalsIgnoreCase("running");
})
.collect(Collectors.toList());
// Build scheduler name lookup
Map<Long, String> schedulerNames = new HashMap<>();
Map<Long, Long> schedulerSuiteIds = new HashMap<>();
schedulerRepository.findAll().forEach(s -> {
schedulerNames.put(s.getId(), s.getTestSuiteName());
schedulerSuiteIds.put(s.getId(), s.getTestSuiteId());
});
Set<Long> existingSuiteIds = testSuiteRepository.findAll().stream().map(s -> s.getId()).collect(Collectors.toSet());
// Group by suite name
Map<String, List<Execution>> bySuite = allExecs.stream()
.filter(e -> {
Long suiteId = schedulerSuiteIds.get(e.getSchedulerId());
return suiteId != null && existingSuiteIds.contains(suiteId);
})
.collect(Collectors.groupingBy(e -> {
String name = schedulerNames.get(e.getSchedulerId());
return name != null ? name : "Suite #" + e.getSchedulerId();
}));
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, List<Execution>> entry : bySuite.entrySet()) {
String suiteName = entry.getKey();
List<Execution> runs = entry.getValue();
int totalRuns = runs.size();
long passedRuns = runs.stream().filter(e ->
e.getStatus().equalsIgnoreCase("success") || e.getStatus().equalsIgnoreCase("completed")
).count();
long failedRuns = runs.stream().filter(e -> e.getStatus().equalsIgnoreCase("failed")).count();
double successRate = totalRuns > 0 ? (double) passedRuns / totalRuns * 100.0 : 0.0;
// Average duration in seconds (only for executions that have both timestamps)
OptionalDouble avgDuration = runs.stream()
.filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null)
.mapToLong(e -> java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds())
.average();
// Most recent run
Optional<LocalDateTime> lastRun = runs.stream()
.map(Execution::getCreatedAt)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder());
Map<String, Object> data = new LinkedHashMap<>();
data.put("suiteName", suiteName);
data.put("totalRuns", totalRuns);
data.put("passedRuns", passedRuns);
data.put("failedRuns", failedRuns);
data.put("successRate", Math.round(successRate * 10.0) / 10.0);
data.put("avgDurationSecs", avgDuration.isPresent() ? Math.round(avgDuration.getAsDouble()) : null);
data.put("lastRunAt", lastRun.orElse(null));
result.add(data);
}
// Sort by successRate descending, then by totalRuns descending as tiebreaker
result.sort((a, b) -> {
int cmp = Double.compare((double) b.get("successRate"), (double) a.get("successRate"));
if (cmp != 0) return cmp;
return Integer.compare((int) b.get("totalRuns"), (int) a.get("totalRuns"));
});
return result.stream().limit(limit).collect(Collectors.toList());
}
/**
* Calculates live fleet health: agent statuses + queue depth.
* An agent is considered ONLINE if it sent a heartbeat within the last 2 minutes.
* An agent is RUNNING if it has an ASSIGNED job with a valid lease.
*/
public Map<String, Object> getFleetHealth(Long orgId) {
// Get all agents for this org
List<Agent> agents = orgId != null
? agentRepository.findByOrgId(orgId)
: agentRepository.findAll();
LocalDateTime onlineThreshold = LocalDateTime.now().minusMinutes(2);
// Get all currently assigned jobs (to detect which agents are running)
List<Job> assignedJobs = jobRepository.findAll().stream()
.filter(j -> "ASSIGNED".equalsIgnoreCase(j.getStatus()))
.filter(j -> j.getLeaseExpiresAt() != null && j.getLeaseExpiresAt().isAfter(LocalDateTime.now()))
.collect(Collectors.toList());
Set<String> busyAgentIds = assignedJobs.stream()
.map(Job::getAgentId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// Count queue depth (QUEUED jobs with no agent yet)
long jobQueueDepth = jobRepository.findAll().stream()
.filter(j -> "QUEUED".equalsIgnoreCase(j.getStatus()))
.count();
// Also count Schedulers that are waiting to be unwrapped by an agent
long activeSchedulers = schedulerRepository.findAll().stream()
.filter(s -> "now".equals(s.getExecutionType()) && "active".equals(s.getStatus()))
.filter(s -> orgId == null || orgId.equals(s.getOrgId()))
.count();
long queueDepth = jobQueueDepth + activeSchedulers;
// Build per-agent summaries
List<Map<String, Object>> agentDetails = new ArrayList<>();
int onlineCount = 0;
int runningCount = 0;
int offlineCount = 0;
for (Agent agent : agents) {
boolean isOnline = agent.getLastSeenAt() != null && agent.getLastSeenAt().isAfter(onlineThreshold);
boolean isRunning = isOnline && busyAgentIds.contains(agent.getId());
String agentStatus;
if (isRunning) { agentStatus = "running"; runningCount++; }
else if (isOnline) { agentStatus = "idle"; onlineCount++; }
else { agentStatus = "offline"; offlineCount++; }
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("id", agent.getId());
detail.put("name", agent.getName());
detail.put("os", agent.getOs());
detail.put("agentVersion", agent.getAgentVersion());
detail.put("status", agentStatus);
detail.put("lastSeenAt", agent.getLastSeenAt());
agentDetails.add(detail);
}
// Sort: running first, then idle, then offline
agentDetails.sort((a, b) -> {
String[] order = {"running", "idle", "offline"};
int ai = Arrays.asList(order).indexOf(a.get("status").toString());
int bi = Arrays.asList(order).indexOf(b.get("status").toString());
return Integer.compare(ai, bi);
});
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalAgents", agents.size());
result.put("onlineCount", onlineCount);
result.put("runningCount", runningCount);
result.put("offlineCount", offlineCount);
result.put("queueDepth", queueDepth);
result.put("agents", agentDetails);
return result;
}
/**
* Calculates the "Time Saved" ROI metric for the org.
*
* Assumptions (industry-standard estimates):
* - Manual test execution time: 15 minutes per test run
* - QA engineer hourly rate: $50 USD
*
* Returns:
* totalRuns, totalAutomatedSecs, manualEstimateSecs, timeSavedSecs,
* hoursSaved, dollarsSaved, avgDurationSecs,
* todayRuns, weekRuns,
* manualMinsPerRun (assumption), hourlyRate (assumption)
*/
public Map<String, Object> getTimeSavedRoi(Long orgId) {
final int MANUAL_MINS_PER_RUN = 15; // assumed manual time per test run
final double HOURLY_RATE = 50.0; // assumed QA hourly rate in USD
List<Execution> completedExecs = executionRepository.findAllByOrderByIdDesc()
.stream()
.filter(e -> orgId == null || orgId.equals(e.getOrgId()))
.filter(e -> {
String s = e.getStatus();
return s != null && (s.equalsIgnoreCase("success")
|| s.equalsIgnoreCase("completed")
|| s.equalsIgnoreCase("failed"));
})
.collect(Collectors.toList());
int totalRuns = completedExecs.size();
// Sum actual automated execution durations
long totalAutomatedSecs = completedExecs.stream()
.filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null)
.mapToLong(e -> Math.max(0, java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds()))
.sum();
OptionalDouble avgDur = completedExecs.stream()
.filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null)
.mapToLong(e -> Math.max(0, java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds()))
.average();
// Manual estimate: each run would take MANUAL_MINS_PER_RUN minutes
long manualEstimateSecs = (long) totalRuns * MANUAL_MINS_PER_RUN * 60L;
// Time saved = manual - automated (floored at 0)
long timeSavedSecs = Math.max(0, manualEstimateSecs - totalAutomatedSecs);
double hoursSaved = timeSavedSecs / 3600.0;
double dollarsSaved = hoursSaved * HOURLY_RATE;
// Today and this week counts
LocalDateTime startOfToday = LocalDateTime.now().toLocalDate().atStartOfDay();
LocalDateTime startOfWeek = startOfToday.minusDays(6);
long todayRuns = completedExecs.stream()
.filter(e -> e.getCreatedAt() != null && e.getCreatedAt().isAfter(startOfToday))
.count();
long weekRuns = completedExecs.stream()
.filter(e -> e.getCreatedAt() != null && e.getCreatedAt().isAfter(startOfWeek))
.count();
// Daily breakdown for last 14 days (for sparkline)
List<Map<String, Object>> dailyBreakdown = new ArrayList<>();
for (int i = 13; i >= 0; i--) {
LocalDateTime dayStart = startOfToday.minusDays(i);
LocalDateTime dayEnd = dayStart.plusDays(1);
long dayRuns = completedExecs.stream()
.filter(e -> e.getCreatedAt() != null
&& !e.getCreatedAt().isBefore(dayStart)
&& e.getCreatedAt().isBefore(dayEnd))
.count();
long daySavedSecs = dayRuns * MANUAL_MINS_PER_RUN * 60L;
Map<String, Object> day = new LinkedHashMap<>();
day.put("date", dayStart.toLocalDate().toString());
day.put("runs", dayRuns);
day.put("savedMins", daySavedSecs / 60);
dailyBreakdown.add(day);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalRuns", totalRuns);
result.put("totalAutomatedSecs", totalAutomatedSecs);
result.put("manualEstimateSecs", manualEstimateSecs);
result.put("timeSavedSecs", timeSavedSecs);
result.put("hoursSaved", Math.round(hoursSaved * 10.0) / 10.0);
result.put("dollarsSaved", Math.round(dollarsSaved * 100.0) / 100.0);
result.put("avgDurationSecs", avgDur.isPresent() ? Math.round(avgDur.getAsDouble()) : 0);
result.put("todayRuns", todayRuns);
result.put("weekRuns", weekRuns);
result.put("manualMinsPerRun", MANUAL_MINS_PER_RUN);
result.put("hourlyRate", HOURLY_RATE);
result.put("dailyBreakdown", dailyBreakdown);
return result;
}
}

View File

@ -0,0 +1,205 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Scheduler;
import com.autopilot.localagent_cloud.repository.SchedulerRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Service
public class CronExecutionEngine {
private static final Logger logger = LoggerFactory.getLogger(CronExecutionEngine.class);
private final SchedulerRepository schedulerRepository;
public CronExecutionEngine(SchedulerRepository schedulerRepository) {
this.schedulerRepository = schedulerRepository;
}
// Runs at the 0th second of every minute
@Scheduled(cron = "0 * * * * *")
public void evaluateCronSchedules() {
LocalDateTime serverNow = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
logger.debug("Evaluating cron schedules at: {}", serverNow);
List<Scheduler> activeSchedules = schedulerRepository.findAll().stream()
.filter(s -> "scheduled".equals(s.getExecutionType()) && "active".equals(s.getStatus()))
.toList();
for (Scheduler schedule : activeSchedules) {
try {
LocalDateTime now = serverNow;
if (schedule.getTimezone() != null && !schedule.getTimezone().isBlank()) {
try {
now = LocalDateTime.now(java.time.ZoneId.of(schedule.getTimezone())).truncatedTo(ChronoUnit.MINUTES);
} catch (Exception e) {
logger.warn("Invalid timezone '{}' for schedule ID {}", schedule.getTimezone(), schedule.getId());
}
}
if (shouldRunOutlookSchedule(schedule, now)) {
logger.info("Outlook schedule triggered for suite: {}", schedule.getTestSuiteName());
queueExecution(schedule);
// For 'once' recurrence, deactivate after triggering
if ("once".equals(schedule.getRecurrenceType())) {
schedule.setStatus("inactive");
schedulerRepository.save(schedule);
}
} else if (hasLegacyCron(schedule)) {
evaluateLegacyCron(schedule, now);
}
} catch (Exception e) {
logger.error("Failed to evaluate scheduler ID {}: {}", schedule.getId(), e.getMessage(), e);
}
}
}
/**
* Evaluates Outlook-style fields (scheduledDate, scheduledTime, recurrenceType, etc.)
* Returns true if the schedule should fire at the given time.
*/
private boolean shouldRunOutlookSchedule(Scheduler s, LocalDateTime now) {
// Only use Outlook mode if recurrenceType is set
if (s.getRecurrenceType() == null || s.getRecurrenceType().isBlank()) {
return false;
}
LocalTime scheduledTime = s.getScheduledTime();
if (scheduledTime == null) {
return false;
}
// Check time match (truncated to minutes)
LocalTime nowTime = now.toLocalTime().truncatedTo(ChronoUnit.MINUTES);
LocalTime targetTime = scheduledTime.truncatedTo(ChronoUnit.MINUTES);
if (!nowTime.equals(targetTime)) {
return false;
}
// Check recurrence end date
if (s.getRecurrenceEndDate() != null && now.toLocalDate().isAfter(s.getRecurrenceEndDate())) {
// Past end date â deactivate
s.setStatus("inactive");
schedulerRepository.save(s);
logger.info("Deactivating scheduler ID {} — past recurrence end date", s.getId());
return false;
}
switch (s.getRecurrenceType()) {
case "once": {
// Must match exact date and time
LocalDate scheduledDate = s.getScheduledDate();
if (scheduledDate == null) return false;
return now.toLocalDate().equals(scheduledDate);
}
case "daily": {
// Check if start date has been reached
LocalDate scheduledDate = s.getScheduledDate();
if (scheduledDate != null && now.toLocalDate().isBefore(scheduledDate)) {
return false;
}
return true; // Time already matched above
}
case "weekly": {
// Check if start date has been reached
LocalDate scheduledDate = s.getScheduledDate();
if (scheduledDate != null && now.toLocalDate().isBefore(scheduledDate)) {
return false;
}
// Match day of week
String recurrenceDays = s.getRecurrenceDays();
if (recurrenceDays == null || recurrenceDays.isBlank()) return false;
String todayAbbr = now.getDayOfWeek().name().substring(0, 3); // MON, TUE, etc.
for (String day : recurrenceDays.split(",")) {
if (day.trim().equalsIgnoreCase(todayAbbr)) return true;
}
return false;
}
case "monthly": {
// Check if start date has been reached
LocalDate scheduledDate = s.getScheduledDate();
if (scheduledDate == null) return false;
if (now.toLocalDate().isBefore(scheduledDate)) return false;
// Match day of month
return now.getDayOfMonth() == scheduledDate.getDayOfMonth();
}
default:
return false;
}
}
/** Returns true if this scheduler has a legacy cron expression and no Outlook fields */
private boolean hasLegacyCron(Scheduler s) {
return s.getCronExpression() != null
&& !s.getCronExpression().isBlank()
&& (s.getRecurrenceType() == null || s.getRecurrenceType().isBlank());
}
/** Evaluates legacy raw cron expression (original behavior) */
private void evaluateLegacyCron(Scheduler schedule, LocalDateTime now) {
try {
CronExpression cron = CronExpression.parse(schedule.getCronExpression());
LocalDateTime nextExecution = cron.next(now.minusSeconds(1));
if (nextExecution != null && nextExecution.truncatedTo(ChronoUnit.MINUTES).equals(now)) {
logger.info("Cron trigger matched for suite: {}", schedule.getTestSuiteName());
queueExecution(schedule);
}
} catch (Exception e) {
logger.error("Failed to parse or evaluate cron expression: {} for scheduler ID: {}",
schedule.getCronExpression(), schedule.getId(), e);
}
}
/**
* Builds a Spring cron expression string from Outlook-style fields.
* Useful for display purposes or for storing a computed cron on the entity.
*/
public static String buildCronFromOutlookFields(Scheduler s) {
if (s.getScheduledTime() == null || s.getRecurrenceType() == null) return null;
int hour = s.getScheduledTime().getHour();
int minute = s.getScheduledTime().getMinute();
return switch (s.getRecurrenceType()) {
case "once", "daily" -> String.format("0 %d %d * * *", minute, hour);
case "weekly" -> {
String days = (s.getRecurrenceDays() != null && !s.getRecurrenceDays().isBlank())
? s.getRecurrenceDays()
: "*";
yield String.format("0 %d %d * * %s", minute, hour, days);
}
case "monthly" -> {
int dayOfMonth = s.getScheduledDate() != null ? s.getScheduledDate().getDayOfMonth() : 1;
yield String.format("0 %d %d %d * *", minute, hour, dayOfMonth);
}
default -> null;
};
}
private void queueExecution(Scheduler original) {
Scheduler job = new Scheduler();
job.setTestSuiteId(original.getTestSuiteId());
job.setTestSuiteName(original.getTestSuiteName());
job.setBrowserType(original.getBrowserType());
job.setBrowserVersion(original.getBrowserVersion());
job.setTargetGroupId(original.getTargetGroupId());
job.setExecutionType("now");
job.setStatus("active");
job.setOrgId(original.getOrgId());
job.setEnvironmentId(original.getEnvironmentId());
schedulerRepository.save(job);
logger.info("Queued test suite '{}' for execution via local agent polling", job.getTestSuiteName());
}
}

View File

@ -0,0 +1,74 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Execution;
import com.autopilot.localagent_cloud.model.Screenshot;
import com.autopilot.localagent_cloud.model.StepResult;
import com.autopilot.localagent_cloud.repository.ExecutionRepository;
import com.autopilot.localagent_cloud.repository.ScreenshotRepository;
import com.autopilot.localagent_cloud.repository.StepResultRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class ExecutionService {
private final ExecutionRepository executionRepository;
private final StepResultRepository stepResultRepository;
private final ScreenshotRepository screenshotRepository;
public ExecutionService(ExecutionRepository executionRepository,
StepResultRepository stepResultRepository,
ScreenshotRepository screenshotRepository) {
this.executionRepository = executionRepository;
this.stepResultRepository = stepResultRepository;
this.screenshotRepository = screenshotRepository;
}
public ResponseEntity<List<Execution>> getAll(Long orgId) {
List<Execution> list = orgId != null
? executionRepository.findAll().stream().filter(e -> orgId.equals(e.getOrgId())).toList()
: executionRepository.findAll();
return ResponseEntity.ok(list);
}
public ResponseEntity<Map<String, Object>> getById(Long id) {
return executionRepository.findById(id).map(exec -> {
List<StepResult> steps = stepResultRepository.findByExecutionId(id);
List<Screenshot> screenshots = screenshotRepository.findByExecutionId(id);
Map<String, Object> detail = new HashMap<>();
detail.put("execution", exec);
detail.put("steps", steps);
detail.put("screenshots", screenshots);
return ResponseEntity.ok(detail);
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Map<String, Object>> getBySchedulerId(Long schedulerId) {
return executionRepository.findFirstBySchedulerIdOrderByIdDesc(schedulerId).map(exec -> {
List<StepResult> steps = stepResultRepository.findByExecutionId(exec.getId());
List<Screenshot> screenshots = screenshotRepository.findByExecutionId(exec.getId());
Map<String, Object> detail = new HashMap<>();
detail.put("execution", exec);
detail.put("steps", steps);
detail.put("screenshots", screenshots);
return ResponseEntity.ok(detail);
}).orElse(ResponseEntity.notFound().build());
}
@org.springframework.transaction.annotation.Transactional
public ResponseEntity<Void> deleteById(Long id) {
if (!executionRepository.existsById(id)) return ResponseEntity.notFound().build();
stepResultRepository.deleteAll(stepResultRepository.findByExecutionId(id));
screenshotRepository.deleteAll(screenshotRepository.findByExecutionId(id));
executionRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}

View File

@ -0,0 +1,96 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Group;
import com.autopilot.localagent_cloud.model.AgentGroupMapping;
import com.autopilot.localagent_cloud.repository.GroupRepository;
import com.autopilot.localagent_cloud.repository.AgentGroupMappingRepository;
import com.autopilot.localagent_cloud.repository.AgentRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class GroupService {
private final GroupRepository groupRepository;
private final AgentGroupMappingRepository agentGroupMappingRepository;
private final AgentRepository agentRepository;
public GroupService(GroupRepository groupRepository,
AgentGroupMappingRepository agentGroupMappingRepository,
AgentRepository agentRepository) {
this.groupRepository = groupRepository;
this.agentGroupMappingRepository = agentGroupMappingRepository;
this.agentRepository = agentRepository;
}
public ResponseEntity<List<Group>> getAll(Long orgId) {
List<Group> list = orgId != null
? groupRepository.findAll().stream().filter(g -> orgId.equals(g.getOrgId())).toList()
: groupRepository.findAll();
return ResponseEntity.ok(list);
}
public ResponseEntity<Group> create(Group group, Long orgId) {
if (group.getName() == null || group.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
group.setOrgId(orgId);
return ResponseEntity.status(HttpStatus.CREATED).body(groupRepository.save(group));
}
public ResponseEntity<Group> update(Long id, Group updated) {
return groupRepository.findById(id).map(existing -> {
if (updated.getName() != null) existing.setName(updated.getName());
if (updated.getDescription() != null) existing.setDescription(updated.getDescription());
return ResponseEntity.ok(groupRepository.save(existing));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(Long id) {
if (!groupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
groupRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
public ResponseEntity<List<Map<String, Object>>> getAgentsInGroup(Long groupId) {
List<AgentGroupMapping> mappings = agentGroupMappingRepository.findByGroupId(groupId);
List<Map<String, Object>> result = new ArrayList<>();
for (AgentGroupMapping m : mappings) {
agentRepository.findById(m.getAgentId()).ifPresent(agent -> {
Map<String, Object> entry = new HashMap<>();
entry.put("mappingId", m.getId());
entry.put("agent", agent);
result.add(entry);
});
}
return ResponseEntity.ok(result);
}
public ResponseEntity<AgentGroupMapping> addAgent(Long groupId, String agentId) {
if (agentId == null || agentId.isBlank()) {
return ResponseEntity.badRequest().build();
}
if (!groupRepository.existsById(groupId)) {
return ResponseEntity.notFound().build();
}
AgentGroupMapping mapping = new AgentGroupMapping();
mapping.setAgentId(agentId);
mapping.setGroupId(groupId);
return ResponseEntity.status(HttpStatus.CREATED).body(agentGroupMappingRepository.save(mapping));
}
@Transactional
public ResponseEntity<Void> removeAgent(Long groupId, String agentId) {
agentGroupMappingRepository.deleteByAgentIdAndGroupId(agentId, groupId);
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,42 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Execution;
import com.autopilot.localagent_cloud.repository.ExecutionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class OrphanedExecutionSweeper {
private static final Logger logger = LoggerFactory.getLogger(OrphanedExecutionSweeper.class);
private final ExecutionRepository executionRepository;
public OrphanedExecutionSweeper(ExecutionRepository executionRepository) {
this.executionRepository = executionRepository;
}
// Run every 5 minutes (300000 ms)
@Scheduled(fixedRate = 300000)
@Transactional
public void sweepOrphanedExecutions() {
// If an execution is stuck in "running" for more than 15 minutes, it is orphaned
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(15);
List<Execution> orphanedExecutions = executionRepository.findByStatusAndCreatedAtBefore("running", cutoff);
if (!orphanedExecutions.isEmpty()) {
logger.warn("Found {} orphaned executions stuck in 'running' state. Marking them as failed.", orphanedExecutions.size());
for (Execution execution : orphanedExecutions) {
execution.setStatus("failed");
execution.setFinishedAt(LocalDateTime.now());
executionRepository.save(execution);
}
}
}
}

View File

@ -0,0 +1,60 @@
package com.autopilot.localagent_cloud.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import jakarta.annotation.PostConstruct;
@Service
public class S3Service {
@Value("${aws.s3.bucket:autopilot}")
private String bucketName;
@Value("${aws.region:ap-south-1}")
private String region;
@Value("${aws.access-key:}")
private String accessKey;
@Value("${aws.secret-key:}")
private String secretKey;
private S3Client s3Client;
@PostConstruct
public void init() {
if (accessKey != null && !accessKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) {
s3Client = S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.build();
} else {
// Fallback to default credentials provider (e.g. IAM role on EC2)
s3Client = S3Client.builder()
.region(Region.of(region))
.build();
}
}
public String uploadImage(String fileName, byte[] imageBytes) {
String key = "screenshots/" + fileName;
PutObjectRequest putOb = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType("image/png")
.build();
s3Client.putObject(putOb, RequestBody.fromBytes(imageBytes));
// Return public URL (assuming bucket allows public read)
return "https://" + bucketName + ".s3." + region + ".amazonaws.com/" + key;
}
}

View File

@ -0,0 +1,111 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.Scheduler;
import com.autopilot.localagent_cloud.repository.SchedulerRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.Map;
@Service
public class SchedulerService {
private final SchedulerRepository schedulerRepository;
public SchedulerService(SchedulerRepository schedulerRepository) {
this.schedulerRepository = schedulerRepository;
}
public ResponseEntity<List<Scheduler>> getAll(Long orgId) {
List<Scheduler> list = orgId != null
? schedulerRepository.findAll().stream().filter(s -> orgId.equals(s.getOrgId())).toList()
: schedulerRepository.findAll();
return ResponseEntity.ok(list);
}
public ResponseEntity<Scheduler> create(Map<String, Object> body, Long orgId) {
Scheduler scheduler = buildSchedulerFromBody(new Scheduler(), body);
scheduler.setOrgId(orgId);
if (scheduler.getTestSuiteName() == null || scheduler.getTestSuiteName().isBlank()) {
return ResponseEntity.badRequest().build();
}
if (scheduler.getExecutionType() == null || scheduler.getExecutionType().isBlank()) {
scheduler.setExecutionType("now");
}
if (scheduler.getBrowserType() == null || scheduler.getBrowserType().isBlank()) {
scheduler.setBrowserType("chrome");
}
if (scheduler.getStatus() == null || scheduler.getStatus().isBlank()) {
scheduler.setStatus("active");
}
// Auto-compute cron expression from Outlook-style fields
if (scheduler.getRecurrenceType() != null && !scheduler.getRecurrenceType().isBlank()) {
String computed = CronExecutionEngine.buildCronFromOutlookFields(scheduler);
if (computed != null) scheduler.setCronExpression(computed);
}
return ResponseEntity.status(HttpStatus.CREATED).body(schedulerRepository.save(scheduler));
}
public ResponseEntity<Scheduler> update(Long id, Map<String, Object> body) {
return schedulerRepository.findById(id).map(existing -> {
buildSchedulerFromBody(existing, body);
// Auto-compute cron expression from Outlook-style fields
if (existing.getRecurrenceType() != null && !existing.getRecurrenceType().isBlank()) {
String computed = CronExecutionEngine.buildCronFromOutlookFields(existing);
if (computed != null) existing.setCronExpression(computed);
}
return ResponseEntity.ok(schedulerRepository.save(existing));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(Long id) {
if (!schedulerRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
schedulerRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
/**
* Maps a JSON request body (from the Outlook-style scheduler form) onto a Scheduler entity.
* Handles both the legacy fields and the new Outlook-style fields.
*/
private Scheduler buildSchedulerFromBody(Scheduler s, Map<String, Object> body) {
if (body.containsKey("testSuiteName")) s.setTestSuiteName((String) body.get("testSuiteName"));
if (body.containsKey("executionType")) s.setExecutionType((String) body.get("executionType"));
if (body.containsKey("browserType")) s.setBrowserType((String) body.get("browserType"));
if (body.containsKey("status")) s.setStatus((String) body.get("status"));
if (body.containsKey("cronExpression")) s.setCronExpression((String) body.get("cronExpression"));
if (body.containsKey("testSuiteId") && body.get("testSuiteId") != null) {
s.setTestSuiteId(((Number) body.get("testSuiteId")).longValue());
}
if (body.containsKey("environmentId") && body.get("environmentId") != null) {
s.setEnvironmentId(((Number) body.get("environmentId")).longValue());
}
// Outlook-style fields
if (body.containsKey("recurrenceType")) s.setRecurrenceType((String) body.get("recurrenceType"));
if (body.containsKey("recurrenceDays")) s.setRecurrenceDays((String) body.get("recurrenceDays"));
if (body.containsKey("scheduledDate") && body.get("scheduledDate") != null) {
try { s.setScheduledDate(LocalDate.parse((String) body.get("scheduledDate"))); } catch (Exception ignored) {}
}
if (body.containsKey("scheduledTime") && body.get("scheduledTime") != null) {
try { s.setScheduledTime(LocalTime.parse((String) body.get("scheduledTime"))); } catch (Exception ignored) {}
}
if (body.containsKey("recurrenceEndDate") && body.get("recurrenceEndDate") != null) {
String endDateStr = (String) body.get("recurrenceEndDate");
if (!endDateStr.isBlank()) {
try { s.setRecurrenceEndDate(LocalDate.parse(endDateStr)); } catch (Exception ignored) {}
} else {
s.setRecurrenceEndDate(null);
}
}
if (body.containsKey("timezone") && body.get("timezone") != null) {
s.setTimezone((String) body.get("timezone"));
}
return s;
}
}

View File

@ -0,0 +1,141 @@
package com.autopilot.localagent_cloud.service;
import com.autopilot.localagent_cloud.model.TestCaseGroup;
import com.autopilot.localagent_cloud.model.TestCaseGroupMapping;
import com.autopilot.localagent_cloud.model.TestStep;
import com.autopilot.localagent_cloud.repository.TestCaseGroupMappingRepository;
import com.autopilot.localagent_cloud.repository.TestCaseGroupRepository;
import com.autopilot.localagent_cloud.repository.TestCaseRepository;
import com.autopilot.localagent_cloud.repository.TestStepRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@Transactional
public class TestCaseGroupService {
private final TestCaseGroupRepository testCaseGroupRepository;
private final TestCaseGroupMappingRepository testCaseGroupMappingRepository;
private final TestCaseRepository testCaseRepository;
private final TestStepRepository testStepRepository;
public TestCaseGroupService(TestCaseGroupRepository testCaseGroupRepository,
TestCaseGroupMappingRepository testCaseGroupMappingRepository,
TestCaseRepository testCaseRepository,
TestStepRepository testStepRepository) {
this.testCaseGroupRepository = testCaseGroupRepository;
this.testCaseGroupMappingRepository = testCaseGroupMappingRepository;
this.testCaseRepository = testCaseRepository;
this.testStepRepository = testStepRepository;
}
public ResponseEntity<List<TestCaseGroup>> getAll(Long orgId) {
List<TestCaseGroup> list = orgId != null
? testCaseGroupRepository.findAllByOrderByIdDesc().stream().filter(g -> orgId.equals(g.getOrgId())).toList()
: testCaseGroupRepository.findAllByOrderByIdDesc();
return ResponseEntity.ok(list);
}
public ResponseEntity<Map<String, Object>> getById(Long id) {
return testCaseGroupRepository.findById(id).map(group -> {
List<TestCaseGroupMapping> mappings = testCaseGroupMappingRepository.findByTestCaseGroupIdOrderByCaseOrder(id);
List<Map<String, Object>> testCases = new ArrayList<>();
for (TestCaseGroupMapping m : mappings) {
testCaseRepository.findById(m.getTestCaseId()).ifPresent(tc -> {
Map<String, Object> entry = new HashMap<>();
entry.put("testCase", tc);
entry.put("caseOrder", m.getCaseOrder());
entry.put("steps", testStepRepository.findByTestCaseIdOrderByStepOrder(tc.getId()));
testCases.add(entry);
});
}
Map<String, Object> detail = new HashMap<>();
detail.put("group", group);
detail.put("testCases", testCases);
return ResponseEntity.ok(detail);
}).orElse(ResponseEntity.notFound().build());
}
@SuppressWarnings("unchecked")
public ResponseEntity<Map<String, Object>> create(Map<String, Object> body, Long orgId) {
String name = (String) body.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().build();
}
TestCaseGroup group = new TestCaseGroup();
group.setName(name);
group.setDescription((String) body.get("description"));
group.setStatus(body.getOrDefault("status", "active").toString());
group.setOrgId(orgId);
group = testCaseGroupRepository.save(group);
List<Number> testCaseIdsRaw = (List<Number>) body.get("testCaseIds");
List<Number> testCaseIds = null;
if (testCaseIdsRaw != null) {
int order = 0;
testCaseIds = new ArrayList<>();
java.util.Set<Long> uniqueIds = new java.util.LinkedHashSet<>();
for (Number tcId : testCaseIdsRaw) {
if (uniqueIds.add(tcId.longValue())) {
testCaseIds.add(tcId);
TestCaseGroupMapping mapping = new TestCaseGroupMapping();
mapping.setTestCaseGroupId(group.getId());
mapping.setTestCaseId(tcId.longValue());
mapping.setCaseOrder(order++);
testCaseGroupMappingRepository.save(mapping);
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("group", group);
result.put("testCaseIds", testCaseIds);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
@SuppressWarnings("unchecked")
public ResponseEntity<Map<String, Object>> update(Long id, Map<String, Object> body) {
return testCaseGroupRepository.findById(id).map(existing -> {
if (body.containsKey("name")) existing.setName((String) body.get("name"));
if (body.containsKey("description")) existing.setDescription((String) body.get("description"));
if (body.containsKey("status")) existing.setStatus((String) body.get("status"));
testCaseGroupRepository.save(existing);
if (body.containsKey("testCaseIds")) {
testCaseGroupMappingRepository.deleteByTestCaseGroupId(id);
List<Number> testCaseIds = (List<Number>) body.get("testCaseIds");
int order = 0;
java.util.Set<Long> uniqueIds = new java.util.LinkedHashSet<>();
for (Number tcId : testCaseIds) {
if (uniqueIds.add(tcId.longValue())) {
TestCaseGroupMapping mapping = new TestCaseGroupMapping();
mapping.setTestCaseGroupId(id);
mapping.setTestCaseId(tcId.longValue());
mapping.setCaseOrder(order++);
testCaseGroupMappingRepository.save(mapping);
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("group", existing);
return ResponseEntity.ok(result);
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(Long id) {
if (!testCaseGroupRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
testCaseGroupRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}

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