Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a5bb6d8
feat: fork development benches into isolated agent workspaces
BreadGenie Oct 2, 2026
deea9fe
feat: add separate bench and site cloning with Admin flows
BreadGenie Oct 2, 2026
4d47c36
feat: clone origin default branches with UI and CLI overrides
BreadGenie Oct 2, 2026
08f2ea4
feat: add collapsible app branch selectors to clone dialog
BreadGenie Oct 2, 2026
a61857a
fix: remove site notice from clone bench dialog
BreadGenie Oct 2, 2026
dfe9ff0
fix: preserve isolation and compatibility when cloning benches and sites
BreadGenie Oct 2, 2026
e565bd6
test: remove redundant clone workflow checks
BreadGenie Oct 2, 2026
d94a702
fix: resolve clone workflow type errors caught by CI
BreadGenie Oct 2, 2026
3cc85f4
fix: protect clone credentials and roll back failed site clones
BreadGenie Oct 2, 2026
1389a3a
fix: remove failed clone host and nginx routes
BreadGenie Oct 2, 2026
97ba12b
fix: keep site action menus on the sites page
BreadGenie Oct 2, 2026
5f3956b
perf: reuse clone artifacts and overlap database imports
BreadGenie Oct 2, 2026
cf7726d
perf: parallelize clone schema and filesystem preparation
BreadGenie Oct 2, 2026
233972d
fix: recover clone caches and report concurrent failures
BreadGenie Oct 2, 2026
89ed686
perf: batch editable installs for cloned benches
BreadGenie Oct 2, 2026
12d538e
perf: seed branch dependencies before yarn reconciliation
BreadGenie Oct 2, 2026
7e99f68
perf: parallelize app cloning and reduce fingerprint IO
BreadGenie Oct 2, 2026
7a9d739
chore: merge latest develop into agent workspaces
BreadGenie Oct 5, 2026
09887fe
perf(git): Reflink independent repository objects
BreadGenie Oct 5, 2026
b9268a1
Merge branch 'develop' into feat/agent-workspaces
BreadGenie Oct 9, 2026
1d9a4d4
perf(dependencies): Cache reconciled Node installs
BreadGenie Oct 9, 2026
5d50916
ci(unit): Refresh package index before installation
BreadGenie Oct 9, 2026
734ea1a
fix(cloning): Address review findings
BreadGenie Oct 9, 2026
bbd7f84
refactor(cloning): Follow validation and path conventions
BreadGenie Oct 9, 2026
2002346
refactor(cloning): Keep functions within complexity limits
BreadGenie Oct 9, 2026
18b901a
chore(branch): Merge latest develop
BreadGenie Oct 11, 2026
790e90a
fix(cloning): Preserve builds and shared Redis lifetimes
BreadGenie Oct 11, 2026
4c27357
fix(runtime): Release leases before shutdown locks
BreadGenie Oct 11, 2026
ce5544d
perf(cloning): Copy shallow Git objects directly
BreadGenie Oct 15, 2026
1801e38
chore(branch): Merge latest develop
BreadGenie Oct 15, 2026
a3ca293
fix(cloning): Preserve concurrent bench settings
BreadGenie Oct 15, 2026
e8756c0
chore(branch): Merge latest develop
BreadGenie Oct 16, 2026
96cfaf7
fix: tighten clone dialog typography and source spacing
BreadGenie Oct 16, 2026
6d6ab23
fix: address clone review findings and refine dialog labels
BreadGenie Oct 16, 2026
1a5fc23
refactor: separate bench and site cloning from deferred forks
BreadGenie Oct 21, 2026
6bfe1b9
Merge latest develop into feat/agent-workspaces
BreadGenie Oct 21, 2026
4367e69
fix: preserve site engines and address cloning review findings
BreadGenie Oct 22, 2026
3190ae5
fix: preserve installation failures and clean up started site imports
BreadGenie Oct 26, 2026
62dc16f
Merge latest develop and resolve cloud settings conflict
BreadGenie Oct 26, 2026
93e586f
fix: address clone task arguments and cleanup review findings
BreadGenie Oct 26, 2026
1da7989
refactor: separate CLI value argument registration
BreadGenie Oct 26, 2026
2715879
fix: improve clone credentials and setup failure reporting
BreadGenie Oct 26, 2026
c7b2c63
fix: validate clone databases and preserve cache reuse across tasks
BreadGenie Oct 26, 2026
c20701b
Merge develop with pilot dev workspaces
BreadGenie Oct 28, 2026
3eb5ead
feat: seed pilot dev setup from an existing site
BreadGenie Oct 28, 2026
d30913e
refactor: move dev site seeding into a follow-up PR
BreadGenie Oct 28, 2026
b3f8da4
fix: isolate clone cleanup failures and validate cached asset paths
BreadGenie Oct 28, 2026
65fbdc9
refactor: follow boolean naming for clone cleanup
BreadGenie Oct 28, 2026
dfe33c0
Merge remote-tracking branch 'origin/develop' into feat/agent-workspaces
BreadGenie Oct 28, 2026
1287414
docs: include cloning routes in generated API reference
BreadGenie Oct 28, 2026
0301d7f
fix: harden cloning caches, repository copies and upload links
BreadGenie Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ See [Admin UI](docs/admin-ui.md) and [Admin API](docs/admin-api.md).
- [Admin UI](docs/admin-ui.md)
- [Production](docs/production.md)
- [Domain Provider](docs/domain-provider.md)
- [Bench and site cloning](docs/cloning.md)

## Development

Expand Down
14 changes: 14 additions & 0 deletions admin/backend/api/v1/benches/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
benches_bp.before_request(guard_bench_management)


@benches_bp.post("/<name>/actions/clone")
def clone_bench(name: str):
from admin.backend.api.v1.cloning import clone_bench as submit

return submit(name)


@benches_bp.get("/<name>/clone-branch-options")
def clone_branch_options(name: str):
from admin.backend.api.v1.cloning import clone_branch_options as read

return read(name)


@benches_bp.get("")
def list_benches():
bench_root = Path(current_app.config["BENCH_ROOT"])
Expand Down
139 changes: 139 additions & 0 deletions admin/backend/api/v1/cloning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import secrets
from pathlib import Path

from flask import current_app, jsonify, request

from admin.backend.api.responses import accepted_task_response, error_response
from admin.backend.api.v1.benches.support import BENCH_NAME_RE, guard_bench_management, target_bench_dir
from admin.backend.api.v1.sites.shared import host_resource_key, task_failure
from admin.backend.providers.apps import AppProvider
from pilot.config import BenchConfig
from pilot.core.bench import Bench
from pilot.core.bench.cloning.branches import validate_branches
from pilot.core.site.clone import SiteClone
from pilot.exceptions import BenchError, TaskConflictError
from pilot.internal.validators import validate_site_name
from pilot.tasks.clone_bench import CloneBenchTask
from pilot.tasks.clone_site import CloneSiteTask


def clone_branch_options(name: str):
root = Path(current_app.config["BENCH_ROOT"])
if not BENCH_NAME_RE.fullmatch(name):
return error_response("invalid_bench_name", "Invalid bench name.", 422)
try:
path = target_bench_dir(root, name)
if not (path / "bench.toml").is_file():
return error_response("bench_not_found", "Source bench not found.", 404)
source = Bench(path)
apps = source.get_clone_branch_options()
provider = AppProvider(path)
for app in apps:
title, _description = provider.get_pyproject_meta(path / "apps" / app["name"], app["name"])
app["title"] = title or app["name"]
return jsonify({"apps": apps})
except BenchError as error:
return error_response("clone_branches_unavailable", str(error), 422)
except ValueError:
return error_response("bench_not_found", "Source bench not found.", 404)
except Exception:
return error_response("clone_branches_unavailable", "Could not load app branches.", 503)


def get_clone_bench_name(name: str, data: dict) -> str:
if not isinstance(data.get("name"), str):
raise BenchError("Enter a destination bench name.")
target = data["name"].strip()
if not BENCH_NAME_RE.fullmatch(name) or not BENCH_NAME_RE.fullmatch(target):
raise BenchError("Invalid bench name.")
return target


def clone_bench(name: str):
root = Path(current_app.config["BENCH_ROOT"])
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error_response("invalid_clone", "Enter a destination bench name.", 422)
try:
target = get_clone_bench_name(name, data)
branch = data.get("branch", "default")
app_branches = data.get("app_branches", {})
validate_branches(branch, app_branches)
BenchConfig.default(target).validate()
source = Bench(target_bench_dir(root, name))
args = dict(source_bench=source.path.name, name=target, branch=branch, app_branches=app_branches)
existing = Bench(root).tasks.find_idempotent_task(
"clone-bench", args, request.headers.get("Idempotency-Key")
)
if existing:
return accepted_task_response(root, existing)
if target_bench_dir(root, target).exists():
return error_response("bench_exists", "Destination bench already exists.", 409)
task_id = CloneBenchTask.queue(
Bench(root),
source_bench=source.path.name,
name=target,
branch=branch,
app_branches=app_branches,
idempotency_key=request.headers.get("Idempotency-Key"),
resource_key=[f"bench:{name}", f"bench:{target}"],
)
except TaskConflictError as error:
return task_failure(error)
except BenchError as error:
return error_response("invalid_clone", str(error), 422)
except Exception as error:
return task_failure(error)
return accepted_task_response(root, task_id)
Comment thread
BreadGenie marked this conversation as resolved.
Resolved


def get_clone_site_names(name: str, data) -> tuple[str, str]:
if not isinstance(data, dict) or not all(
isinstance(data.get(key), str) for key in ("name", "target_bench")
):
raise BenchError("Enter a site name and destination bench.")
target = data["name"].strip()
bench_name = data["target_bench"].strip()
if validate_site_name(name) or validate_site_name(target) or not BENCH_NAME_RE.fullmatch(bench_name):
raise BenchError("Invalid site or bench name.")
return target, bench_name


def clone_site(name: str):
root = Path(current_app.config["BENCH_ROOT"])
data = request.get_json(silent=True)
try:
target, bench_name = get_clone_site_names(name, data)
except BenchError as error:
return error_response("invalid_clone", str(error), 422)
if bench_name != root.name and (response := guard_bench_management()):
Comment thread
BreadGenie marked this conversation as resolved.
Resolved
return response
Comment thread
BreadGenie marked this conversation as resolved.
Resolved
try:
bench = Bench(root)
destination = Bench(target_bench_dir(root, bench_name))
password = secrets.token_urlsafe(24)
args = dict(site=name, name=target, target_bench=bench_name, admin_password=password)
existing = bench.tasks.find_idempotent_task(
"clone-site", args, request.headers.get("Idempotency-Key")
)
if existing:
return accepted_task_response(root, existing)
SiteClone(bench.site(name), destination, target, password).validate()
Comment thread
BreadGenie marked this conversation as resolved.
Resolved
task_id = CloneSiteTask.queue(
bench,
site=name,
name=target,
target_bench=bench_name,
admin_password=password,
idempotency_key=request.headers.get("Idempotency-Key"),
resource_key=[f"site:{name.lower()}", f"bench:{bench_name}", host_resource_key(target)],
)
Comment thread
BreadGenie marked this conversation as resolved.
Resolved
except TaskConflictError as error:
return task_failure(error)
except BenchError as error:
return error_response("invalid_clone", str(error), 422)
except Exception as error:
return task_failure(error)
return accepted_task_response(root, task_id)
7 changes: 7 additions & 0 deletions admin/backend/api/v1/sites/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ def create_site():
return accepted_task_response(bench_root, task_id)


@sites_bp.post("/<name>/actions/clone")
def clone_site(name: str):
from admin.backend.api.v1.cloning import clone_site as submit

return submit(name)
Comment thread
frappe-guard[bot] marked this conversation as resolved.
Resolved
Comment thread
BreadGenie marked this conversation as resolved.
Resolved


@sites_bp.delete("/<name>")
@require_scope(site_name)
def drop_site(name: str):
Expand Down
11 changes: 11 additions & 0 deletions admin/backend/api/v1/types/benches.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,14 @@ class CreatedBench(BenchResource):

class BenchReadiness(TypedDict):
ready: bool


class CloneAppBranches(TypedDict):
name: str
title: str
default_branch: str
branches: list[str]


class CloneBranchOptions(TypedDict):
apps: list[CloneAppBranches]
23 changes: 22 additions & 1 deletion admin/frontend/dashboard/src/api/benches.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import { request } from '@/api/client'
import type { BenchReadiness, BenchResource, CreatedBench } from '@/types/benches'
import type {
BenchReadiness,
BenchResource,
CreatedBench,
CloneBranchOptions,
} from '@/types/benches'
import type { WildcardDomains } from '@/types/sites'
import type { TaskPayload } from '@/types/tasks'

export const benchesApi = {
list: (): Promise<BenchResource[]> => request.get('benches').json(),

cloneBranchOptions: (source: string): Promise<CloneBranchOptions> =>
request.get(`benches/${encodeURIComponent(source)}/clone-branch-options`).json(),

clone: (
source: string,
name: string,
branch = 'default',
app_branches: Record<string, string> = {},
): Promise<TaskPayload> =>
request
.post(`benches/${encodeURIComponent(source)}/actions/clone`, {
json: { name, branch, app_branches },
})
.json(),

start: (name: string): Promise<BenchResource> =>
request.post(`benches/${encodeURIComponent(name)}/actions/start`).json(),

Expand Down
42 changes: 35 additions & 7 deletions admin/frontend/dashboard/src/api/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const sitesApi = {
create: (payload: Record<string, unknown>): Promise<TaskPayload> =>
request.post('sites', { json: payload }).json(),

clone: (source: string, name: string, targetBench: string): Promise<TaskPayload> =>
request
.post(`sites/${encodeURIComponent(source)}/actions/clone`, {
json: { name, target_bench: targetBench },
})
.json(),

loginLink: (name: string): Promise<SiteLoginLink> =>
request.post(`sites/${encodeURIComponent(name)}/login`).json(),

Expand All @@ -47,7 +54,11 @@ export const sitesApi = {
unwrap(request.get(`sites/${encodeURIComponent(name)}/configuration`).json()),
update: (name: string, patch: SiteConfig): Promise<SiteConfig> =>
unwrap(
request.patch(`sites/${encodeURIComponent(name)}/configuration`, { json: patch }).json(),
request
.patch(`sites/${encodeURIComponent(name)}/configuration`, {
json: patch,
})
.json(),
),
},

Expand Down Expand Up @@ -201,7 +212,10 @@ export const sitesApi = {
request.get(`sites/${encodeURIComponent(name)}/apps`).json(),
install: (name: string, payload: Record<string, unknown>): Promise<EnabledApp | TaskPayload> =>
request
.post(`sites/${encodeURIComponent(name)}/apps`, { json: payload, timeout: inlineTimeout })
.post(`sites/${encodeURIComponent(name)}/apps`, {
json: payload,
timeout: inlineTimeout,
})
.json(),
remove: (
name: string,
Expand All @@ -226,7 +240,9 @@ export const sitesApi = {
request.get(`sites/${encodeURIComponent(name)}/domains`).json(),
add: (name: string, domain: string, email = ''): Promise<TaskPayload> =>
request
.post(`sites/${encodeURIComponent(name)}/domains`, { json: email ? { domain, email } : { domain } })
.post(`sites/${encodeURIComponent(name)}/domains`, {
json: email ? { domain, email } : { domain },
})
.json(),
remove: (name: string, domain: string): Promise<TaskPayload> =>
request
Expand All @@ -248,19 +264,27 @@ export const sitesApi = {
monitoring: {
get: (name: string, window: string): Promise<SiteAnalytics> =>
request
.get(`sites/${encodeURIComponent(name)}/monitoring`, { searchParams: { window } })
.get(`sites/${encodeURIComponent(name)}/monitoring`, {
searchParams: { window },
})
.json(),
},

uptime: {
get: (name: string, window: string): Promise<SiteUptime> =>
request.get(`sites/${encodeURIComponent(name)}/uptime`, { searchParams: { window } }).json(),
request
.get(`sites/${encodeURIComponent(name)}/uptime`, {
searchParams: { window },
})
.json(),
},

backups: {
list: (name: string, limit?: number): Promise<Backup[]> =>
request
.get(`sites/${encodeURIComponent(name)}/backups`, { searchParams: limit ? { limit } : {} })
.get(`sites/${encodeURIComponent(name)}/backups`, {
searchParams: limit ? { limit } : {},
})
.json(),
create: (name: string): Promise<TaskPayload> =>
request.post(`sites/${encodeURIComponent(name)}/backups`).json(),
Expand All @@ -278,7 +302,11 @@ export const sitesApi = {
get: (name: string): Promise<BackupSchedule> =>
request.get(`sites/${encodeURIComponent(name)}/backup-schedule`).json(),
set: (name: string, payload: Record<string, unknown>): Promise<BackupSchedule> =>
request.put(`sites/${encodeURIComponent(name)}/backup-schedule`, { json: payload }).json(),
request
.put(`sites/${encodeURIComponent(name)}/backup-schedule`, {
json: payload,
})
.json(),
remove: (name: string) => request.delete(`sites/${encodeURIComponent(name)}/backup-schedule`),
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import LucidePlay from '~icons/lucide/play'
import LucideRotateCw from '~icons/lucide/rotate-cw'
import LucideSquare from '~icons/lucide/square'
import LucideTrash2 from '~icons/lucide/trash-2'
import LucideCopy from '~icons/lucide/copy'

import ActionMenu from '@/components/common/ActionMenu.vue'
import type { ActionMenuOption } from '@/components/common/actionMenu'
Expand All @@ -28,7 +29,7 @@ interface Props {
}

const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'new-bench'])
const emit = defineEmits(['update:modelValue', 'new-bench', 'clone-bench'])

const show = computed({
get: () => props.modelValue,
Expand Down Expand Up @@ -138,8 +139,20 @@ const openBench = (bench: BenchResource) => {

const menuOptions = (bench: BenchResource) => {
const opts: ActionMenuOption[] = []
opts.push({
label: 'Clone bench',
icon: LucideCopy,
onClick: () => {
show.value = false
emit('clone-bench', bench.name)
},
})
Comment thread
BreadGenie marked this conversation as resolved.
Resolved
if (canOpen(bench))
opts.push({ label: 'Open', icon: LucideExternalLink, onClick: () => openBench(bench) })
opts.push({
label: 'Open',
icon: LucideExternalLink,
onClick: () => openBench(bench),
})
if (bench.production) {
const running = bench.workload_running
const current = isCurrentBench(bench)
Expand Down Expand Up @@ -251,7 +264,11 @@ watch(show, (open) => {
<Dialog v-model="showDropConfirm" title="Drop Bench" size="sm">
<div class="flex flex-col gap-4" @pointerdown.stop>
<div class="flex flex-col gap-2 text-ink-gray-7 text-sm leading-relaxed">
<p>Permanently delete <strong class="text-ink-gray-9">{{ benchToDrop?.name }}</strong>?</p>
<p>
Permanently delete
<strong class="text-ink-gray-9">{{ benchToDrop?.name }}</strong
>?
</p>

<p>
This tears down its production services, nginx config and MariaDB instance, then removes
Expand Down
Loading
Loading